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