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