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