1// Inspired by the git-appraise project
2
3// Package input contains helpers to use a text editor as an input for
4// various field of a bug
5package input
6
7import (
8 "bufio"
9 "bytes"
10 "fmt"
11 "io/ioutil"
12 "os"
13 "os/exec"
14 "strings"
15
16 "github.com/MichaelMure/git-bug/repository"
17 "github.com/pkg/errors"
18)
19
20const messageFilename = "BUG_MESSAGE_EDITMSG"
21
22// ErrEmptyMessage is returned when the required message has not been entered
23var ErrEmptyMessage = errors.New("empty message")
24
25// ErrEmptyMessage is returned when the required title has not been entered
26var ErrEmptyTitle = errors.New("empty title")
27
28const bugTitleCommentTemplate = `%s%s
29
30# Please enter the title and comment message. The first non-empty line will be
31# used as the title. Lines starting with '#' will be ignored.
32# An empty title aborts the operation.
33`
34
35// BugCreateEditorInput will open the default editor in the terminal with a
36// template for the user to fill. The file is then processed to extract title
37// and message.
38func BugCreateEditorInput(repo repository.RepoCommon, preTitle string, preMessage string) (string, string, error) {
39 if preMessage != "" {
40 preMessage = "\n\n" + preMessage
41 }
42
43 template := fmt.Sprintf(bugTitleCommentTemplate, preTitle, preMessage)
44
45 raw, err := launchEditorWithTemplate(repo, messageFilename, template)
46
47 if err != nil {
48 return "", "", err
49 }
50
51 lines := strings.Split(raw, "\n")
52
53 var title string
54 var buffer bytes.Buffer
55 for _, line := range lines {
56 if strings.HasPrefix(line, "#") {
57 continue
58 }
59
60 if title == "" {
61 trimmed := strings.TrimSpace(line)
62 if trimmed != "" {
63 title = trimmed
64 }
65 continue
66 }
67
68 buffer.WriteString(line)
69 buffer.WriteString("\n")
70 }
71
72 if title == "" {
73 return "", "", ErrEmptyTitle
74 }
75
76 message := strings.TrimSpace(buffer.String())
77
78 return title, message, nil
79}
80
81const bugCommentTemplate = `%s
82
83# Please enter the comment message. Lines starting with '#' will be ignored,
84# and an empty message aborts the operation.
85`
86
87// BugCommentEditorInput will open the default editor in the terminal with a
88// template for the user to fill. The file is then processed to extract a comment.
89func BugCommentEditorInput(repo repository.RepoCommon, preMessage string) (string, error) {
90 template := fmt.Sprintf(bugCommentTemplate, preMessage)
91 raw, err := launchEditorWithTemplate(repo, messageFilename, template)
92
93 if err != nil {
94 return "", err
95 }
96
97 lines := strings.Split(raw, "\n")
98
99 var buffer bytes.Buffer
100 for _, line := range lines {
101 if strings.HasPrefix(line, "#") {
102 continue
103 }
104 buffer.WriteString(line)
105 buffer.WriteString("\n")
106 }
107
108 message := strings.TrimSpace(buffer.String())
109
110 if message == "" {
111 return "", ErrEmptyMessage
112 }
113
114 return message, nil
115}
116
117const bugTitleTemplate = `%s
118
119# Please enter the new title. Only one line will used.
120# Lines starting with '#' will be ignored, and an empty title aborts the operation.
121`
122
123// BugTitleEditorInput will open the default editor in the terminal with a
124// template for the user to fill. The file is then processed to extract a title.
125func BugTitleEditorInput(repo repository.RepoCommon, preTitle string) (string, error) {
126 template := fmt.Sprintf(bugTitleTemplate, preTitle)
127 raw, err := launchEditorWithTemplate(repo, messageFilename, template)
128
129 if err != nil {
130 return "", err
131 }
132
133 lines := strings.Split(raw, "\n")
134
135 var title string
136 for _, line := range lines {
137 if strings.HasPrefix(line, "#") {
138 continue
139 }
140 trimmed := strings.TrimSpace(line)
141 if trimmed == "" {
142 continue
143 }
144 title = trimmed
145 break
146 }
147
148 if title == "" {
149 return "", ErrEmptyTitle
150 }
151
152 return title, nil
153}
154
155const queryTemplate = `%s
156
157# Please edit the bug query.
158# Lines starting with '#' will be ignored, and an empty query aborts the operation.
159#
160# Example: status:open author:"rené descartes" sort:edit
161#
162# Valid filters are:
163#
164# - status:open, status:closed
165# - author:<query>
166# - label:<label>
167# - no:label
168#
169# Sorting
170#
171# - sort:id, sort:id-desc, sort:id-asc
172# - sort:creation, sort:creation-desc, sort:creation-asc
173# - sort:edit, sort:edit-desc, sort:edit-asc
174#
175# Notes
176#
177# - queries are case insensitive.
178# - you can combine as many qualifiers as you want.
179# - you can use double quotes for multi-word search terms (ex: author:"René Descartes")
180`
181
182// QueryEditorInput will open the default editor in the terminal with a
183// template for the user to fill. The file is then processed to extract a query.
184func QueryEditorInput(repo repository.RepoCommon, preQuery string) (string, error) {
185 template := fmt.Sprintf(queryTemplate, preQuery)
186 raw, err := launchEditorWithTemplate(repo, messageFilename, template)
187
188 if err != nil {
189 return "", err
190 }
191
192 lines := strings.Split(raw, "\n")
193
194 for _, line := range lines {
195 if strings.HasPrefix(line, "#") {
196 continue
197 }
198 trimmed := strings.TrimSpace(line)
199 if trimmed == "" {
200 continue
201 }
202 return trimmed, nil
203 }
204
205 return "", nil
206}
207
208// launchEditorWithTemplate will launch an editor as launchEditor do, but with a
209// provided template.
210func launchEditorWithTemplate(repo repository.RepoCommon, fileName string, template string) (string, error) {
211 path := fmt.Sprintf("%s/.git/%s", repo.GetPath(), fileName)
212
213 err := ioutil.WriteFile(path, []byte(template), 0644)
214
215 if err != nil {
216 return "", err
217 }
218
219 return launchEditor(repo, fileName)
220}
221
222// launchEditor launches the default editor configured for the given repo. This
223// method blocks until the editor command has returned.
224//
225// The specified filename should be a temporary file and provided as a relative path
226// from the repo (e.g. "FILENAME" will be converted to ".git/FILENAME"). This file
227// will be deleted after the editor is closed and its contents have been read.
228//
229// This method returns the text that was read from the temporary file, or
230// an error if any step in the process failed.
231func launchEditor(repo repository.RepoCommon, fileName string) (string, error) {
232 path := fmt.Sprintf("%s/.git/%s", repo.GetPath(), fileName)
233 defer os.Remove(path)
234
235 editor, err := repo.GetCoreEditor()
236 if err != nil {
237 return "", fmt.Errorf("Unable to detect default git editor: %v\n", err)
238 }
239
240 cmd, err := startInlineCommand(editor, path)
241 if err != nil {
242 // Running the editor directly did not work. This might mean that
243 // the editor string is not a path to an executable, but rather
244 // a shell command (e.g. "emacsclient --tty"). As such, we'll try
245 // to run the command through bash, and if that fails, try with sh
246 args := []string{"-c", fmt.Sprintf("%s %q", editor, path)}
247 cmd, err = startInlineCommand("bash", args...)
248 if err != nil {
249 cmd, err = startInlineCommand("sh", args...)
250 }
251 }
252 if err != nil {
253 return "", fmt.Errorf("Unable to start editor: %v\n", err)
254 }
255
256 if err := cmd.Wait(); err != nil {
257 return "", fmt.Errorf("Editing finished with error: %v\n", err)
258 }
259
260 output, err := ioutil.ReadFile(path)
261
262 if err != nil {
263 return "", fmt.Errorf("Error reading edited file: %v\n", err)
264 }
265
266 return string(output), err
267}
268
269// FromFile loads and returns the contents of a given file. If - is passed
270// through, much like git, it will read from stdin. This can be piped data,
271// unless there is a tty in which case the user will be prompted to enter a
272// message.
273func FromFile(fileName string) (string, error) {
274 if fileName == "-" {
275 stat, err := os.Stdin.Stat()
276 if err != nil {
277 return "", fmt.Errorf("Error reading from stdin: %v\n", err)
278 }
279 if (stat.Mode() & os.ModeCharDevice) == 0 {
280 // There is no tty. This will allow us to read piped data instead.
281 output, err := ioutil.ReadAll(os.Stdin)
282 if err != nil {
283 return "", fmt.Errorf("Error reading from stdin: %v\n", err)
284 }
285 return string(output), err
286 }
287
288 fmt.Printf("(reading comment from standard input)\n")
289 var output bytes.Buffer
290 s := bufio.NewScanner(os.Stdin)
291 for s.Scan() {
292 output.Write(s.Bytes())
293 output.WriteRune('\n')
294 }
295 return output.String(), nil
296 }
297
298 output, err := ioutil.ReadFile(fileName)
299 if err != nil {
300 return "", fmt.Errorf("Error reading file: %v\n", err)
301 }
302 return string(output), err
303}
304
305func startInlineCommand(command string, args ...string) (*exec.Cmd, error) {
306 cmd := exec.Command(command, args...)
307 cmd.Stdin = os.Stdin
308 cmd.Stdout = os.Stdout
309 cmd.Stderr = os.Stderr
310 err := cmd.Start()
311 return cmd, err
312}