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# - title:<title>
197# - label:<label>
198# - no:label
199#
200# Sorting
201#
202# - sort:id, sort:id-desc, sort:id-asc
203# - sort:creation, sort:creation-desc, sort:creation-asc
204# - sort:edit, sort:edit-desc, sort:edit-asc
205#
206# Notes
207#
208# - queries are case insensitive.
209# - you can combine as many qualifiers as you want.
210# - you can use double quotes for multi-word search terms (ex: author:"René Descartes")
211`
212
213// QueryEditorInput will open the default editor in the terminal with a
214// template for the user to fill. The file is then processed to extract a query.
215func QueryEditorInput(repo repository.RepoCommon, preQuery string) (string, error) {
216 template := fmt.Sprintf(queryTemplate, preQuery)
217 raw, err := launchEditorWithTemplate(repo, messageFilename, template)
218
219 if err != nil {
220 return "", err
221 }
222
223 lines := strings.Split(raw, "\n")
224
225 for _, line := range lines {
226 if strings.HasPrefix(line, "#") {
227 continue
228 }
229 trimmed := strings.TrimSpace(line)
230 if trimmed == "" {
231 continue
232 }
233 return trimmed, nil
234 }
235
236 return "", nil
237}
238
239// launchEditorWithTemplate will launch an editor as launchEditor do, but with a
240// provided template.
241func launchEditorWithTemplate(repo repository.RepoCommon, fileName string, template string) (string, error) {
242 path := fmt.Sprintf("%s/%s", repo.GetPath(), fileName)
243
244 err := ioutil.WriteFile(path, []byte(template), 0644)
245
246 if err != nil {
247 return "", err
248 }
249
250 return launchEditor(repo, fileName)
251}
252
253// launchEditor launches the default editor configured for the given repo. This
254// method blocks until the editor command has returned.
255//
256// The specified filename should be a temporary file and provided as a relative path
257// from the repo (e.g. "FILENAME" will be converted to "[<reporoot>/].git/FILENAME"). This file
258// will be deleted after the editor is closed and its contents have been read.
259//
260// This method returns the text that was read from the temporary file, or
261// an error if any step in the process failed.
262func launchEditor(repo repository.RepoCommon, fileName string) (string, error) {
263 path := fmt.Sprintf("%s/%s", repo.GetPath(), fileName)
264 defer os.Remove(path)
265
266 editor, err := repo.GetCoreEditor()
267 if err != nil {
268 return "", fmt.Errorf("Unable to detect default git editor: %v\n", err)
269 }
270
271 cmd, err := startInlineCommand(editor, path)
272 if err != nil {
273 // Running the editor directly did not work. This might mean that
274 // the editor string is not a path to an executable, but rather
275 // a shell command (e.g. "emacsclient --tty"). As such, we'll try
276 // to run the command through bash, and if that fails, try with sh
277 args := []string{"-c", fmt.Sprintf("%s %q", editor, path)}
278 cmd, err = startInlineCommand("bash", args...)
279 if err != nil {
280 cmd, err = startInlineCommand("sh", args...)
281 }
282 }
283 if err != nil {
284 return "", fmt.Errorf("Unable to start editor: %v\n", err)
285 }
286
287 if err := cmd.Wait(); err != nil {
288 return "", fmt.Errorf("Editing finished with error: %v\n", err)
289 }
290
291 output, err := ioutil.ReadFile(path)
292
293 if err != nil {
294 return "", fmt.Errorf("Error reading edited file: %v\n", err)
295 }
296
297 return string(output), err
298}
299
300// fromFile loads and returns the contents of a given file. If - is passed
301// through, much like git, it will read from stdin. This can be piped data,
302// unless there is a tty in which case the user will be prompted to enter a
303// message.
304func fromFile(fileName string) (string, error) {
305 if fileName == "-" {
306 stat, err := os.Stdin.Stat()
307 if err != nil {
308 return "", fmt.Errorf("Error reading from stdin: %v\n", err)
309 }
310 if (stat.Mode() & os.ModeCharDevice) == 0 {
311 // There is no tty. This will allow us to read piped data instead.
312 output, err := ioutil.ReadAll(os.Stdin)
313 if err != nil {
314 return "", fmt.Errorf("Error reading from stdin: %v\n", err)
315 }
316 return string(output), err
317 }
318
319 fmt.Printf("(reading comment from standard input)\n")
320 var output bytes.Buffer
321 s := bufio.NewScanner(os.Stdin)
322 for s.Scan() {
323 output.Write(s.Bytes())
324 output.WriteRune('\n')
325 }
326 return output.String(), nil
327 }
328
329 output, err := ioutil.ReadFile(fileName)
330 if err != nil {
331 return "", fmt.Errorf("Error reading file: %v\n", err)
332 }
333 return string(output), err
334}
335
336func startInlineCommand(command string, args ...string) (*exec.Cmd, error) {
337 cmd := exec.Command(command, args...)
338 cmd.Stdin = os.Stdin
339 cmd.Stdout = os.Stdout
340 cmd.Stderr = os.Stderr
341 err := cmd.Start()
342 return cmd, err
343}