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