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