input.go

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