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