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