input.go

  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}