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