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