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