Skip to content

Commit f7d0988

Browse files
committed
Offer different editor modes during release create
1 parent 4f6021a commit f7d0988

File tree

7 files changed

+284
-123
lines changed

7 files changed

+284
-123
lines changed

pkg/cmd/release/create/create.go

Lines changed: 138 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
package create
22

33
import (
4+
"bytes"
45
"fmt"
56
"io/ioutil"
67
"net/http"
8+
"os"
9+
"os/exec"
710
"strings"
811

912
"github.com/AlecAivazis/survey/v2"
1013
"github.com/cli/cli/internal/config"
1114
"github.com/cli/cli/internal/ghrepo"
15+
"github.com/cli/cli/internal/run"
1216
"github.com/cli/cli/pkg/cmd/release/shared"
1317
"github.com/cli/cli/pkg/cmdutil"
1418
"github.com/cli/cli/pkg/iostreams"
1519
"github.com/cli/cli/pkg/prompt"
1620
"github.com/cli/cli/pkg/surveyext"
21+
"github.com/cli/cli/pkg/text"
1722
"github.com/spf13/cobra"
1823
)
1924

@@ -35,6 +40,11 @@ type CreateOptions struct {
3540

3641
// for interactive flow
3742
SubmitAction string
43+
// for interactive flow
44+
ReleaseNotesAction string
45+
46+
// the value from the --repo flag
47+
RepoOverride string
3848

3949
// maximum number of simultaneous uploads
4050
Concurrency int
@@ -56,6 +66,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
5666
RunE: func(cmd *cobra.Command, args []string) error {
5767
// support `-R, --repo` override
5868
opts.BaseRepo = f.BaseRepo
69+
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
5970

6071
opts.TagName = args[0]
6172

@@ -116,6 +127,35 @@ func createRun(opts *CreateOptions) error {
116127
return err
117128
}
118129

130+
var tagDescription string
131+
var generatedChangelog string
132+
if opts.RepoOverride == "" {
133+
headRef := opts.TagName
134+
tagDescription, _ = gitTagInfo(opts.TagName)
135+
if tagDescription == "" {
136+
if opts.Target != "" {
137+
// TODO: use the remote-tracking version of the branch ref
138+
headRef = opts.Target
139+
} else {
140+
headRef = "HEAD"
141+
}
142+
}
143+
144+
if prevTag, err := detectPreviousTag(headRef); err == nil {
145+
commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef))
146+
generatedChangelog = generateChangelog(commits)
147+
}
148+
}
149+
150+
editorOptions := []string{"Write my own"}
151+
if generatedChangelog != "" {
152+
editorOptions = append(editorOptions, "Write using commit log as template")
153+
}
154+
if tagDescription != "" {
155+
editorOptions = append(editorOptions, "Write using git tag message as template")
156+
}
157+
editorOptions = append(editorOptions, "Leave blank")
158+
119159
qs := []*survey.Question{
120160
{
121161
Name: "name",
@@ -125,18 +165,46 @@ func createRun(opts *CreateOptions) error {
125165
},
126166
},
127167
{
128-
Name: "body",
129-
Prompt: &surveyext.GhEditor{
130-
BlankAllowed: true,
131-
EditorCommand: editorCommand,
132-
Editor: &survey.Editor{
133-
Message: "Release notes",
134-
FileName: "*.md",
135-
Default: opts.Body,
136-
HideDefault: true,
137-
},
168+
Name: "releaseNotesAction",
169+
Prompt: &survey.Select{
170+
Message: "Release notes",
171+
Options: editorOptions,
138172
},
139173
},
174+
}
175+
err = prompt.SurveyAsk(qs, opts)
176+
if err != nil {
177+
return fmt.Errorf("could not prompt: %w", err)
178+
}
179+
180+
var openEditor bool
181+
var editorContents string
182+
183+
switch opts.ReleaseNotesAction {
184+
case "Write my own":
185+
openEditor = true
186+
case "Write using commit log as template":
187+
openEditor = true
188+
editorContents = generatedChangelog
189+
case "Write using git tag message as template":
190+
openEditor = true
191+
editorContents = tagDescription
192+
case "Leave blank":
193+
openEditor = false
194+
default:
195+
return fmt.Errorf("invalid action: %v", opts.ReleaseNotesAction)
196+
}
197+
198+
if openEditor {
199+
// TODO: consider using iostreams here
200+
text, err := surveyext.Edit(editorCommand, "*.md", editorContents, os.Stdin, os.Stdout, os.Stderr, nil)
201+
if err != nil {
202+
return err
203+
}
204+
opts.Body = text
205+
}
206+
207+
qs = []*survey.Question{
140208
{
141209
Name: "prerelease",
142210
Prompt: &survey.Confirm{
@@ -169,6 +237,8 @@ func createRun(opts *CreateOptions) error {
169237
opts.Draft = true
170238
case "Cancel":
171239
return cmdutil.SilentError
240+
default:
241+
return fmt.Errorf("invalid action: %v", opts.SubmitAction)
172242
}
173243
}
174244

@@ -220,3 +290,61 @@ func createRun(opts *CreateOptions) error {
220290

221291
return nil
222292
}
293+
294+
func gitTagInfo(tagName string) (string, error) {
295+
cmd := exec.Command("git", "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
296+
b, err := run.PrepareCmd(cmd).Output()
297+
return string(b), err
298+
}
299+
300+
func detectPreviousTag(headRef string) (string, error) {
301+
cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef))
302+
b, err := run.PrepareCmd(cmd).Output()
303+
return strings.TrimSpace(string(b)), err
304+
}
305+
306+
type logEntry struct {
307+
Subject string
308+
Body string
309+
}
310+
311+
func changelogForRange(refRange string) ([]logEntry, error) {
312+
cmd := exec.Command("git", "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange)
313+
b, err := run.PrepareCmd(cmd).Output()
314+
if err != nil {
315+
return nil, err
316+
}
317+
318+
var entries []logEntry
319+
for _, cb := range bytes.Split(b, []byte{'\000'}) {
320+
c := strings.ReplaceAll(string(cb), "\r\n", "\n")
321+
c = strings.TrimPrefix(c, "\n")
322+
if len(c) == 0 {
323+
continue
324+
}
325+
parts := strings.SplitN(c, "\n\n", 2)
326+
var body string
327+
subject := strings.ReplaceAll(parts[0], "\n", " ")
328+
if len(parts) > 1 {
329+
body = parts[1]
330+
}
331+
entries = append(entries, logEntry{
332+
Subject: subject,
333+
Body: body,
334+
})
335+
}
336+
337+
return entries, nil
338+
}
339+
340+
func generateChangelog(commits []logEntry) string {
341+
var parts []string
342+
for _, c := range commits {
343+
// TODO: consider rendering "Merge pull request #123 from owner/branch" differently
344+
parts = append(parts, fmt.Sprintf("* %s", c.Subject))
345+
if c.Body != "" {
346+
parts = append(parts, text.Indent(c.Body, " "))
347+
}
348+
}
349+
return strings.Join(parts, "\n\n")
350+
}

pkg/cmd/root/help.go

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ package root
33
import (
44
"bytes"
55
"fmt"
6-
"regexp"
76
"strings"
87

8+
"github.com/cli/cli/pkg/text"
99
"github.com/cli/cli/utils"
1010
"github.com/spf13/cobra"
1111
)
@@ -28,7 +28,7 @@ func rootUsageFunc(command *cobra.Command) error {
2828
flagUsages := command.LocalFlags().FlagUsages()
2929
if flagUsages != "" {
3030
command.Println("\n\nFlags:")
31-
command.Print(indent(dedent(flagUsages), " "))
31+
command.Print(text.Indent(dedent(flagUsages), " "))
3232
}
3333
return nil
3434
}
@@ -150,7 +150,7 @@ Read the manual at https://cli.github.com/manual`})
150150
if e.Title != "" {
151151
// If there is a title, add indentation to each line in the body
152152
fmt.Fprintln(out, utils.Bold(e.Title))
153-
fmt.Fprintln(out, indent(strings.Trim(e.Body, "\r\n"), " "))
153+
fmt.Fprintln(out, text.Indent(strings.Trim(e.Body, "\r\n"), " "))
154154
} else {
155155
// If there is no title print the body as is
156156
fmt.Fprintln(out, e.Body)
@@ -165,15 +165,6 @@ func rpad(s string, padding int) string {
165165
return fmt.Sprintf(template, s)
166166
}
167167

168-
var lineRE = regexp.MustCompile(`(?m)^`)
169-
170-
func indent(s, indent string) string {
171-
if len(strings.TrimSpace(s)) == 0 {
172-
return s
173-
}
174-
return lineRE.ReplaceAllLiteralString(s, indent)
175-
}
176-
177168
func dedent(s string) string {
178169
lines := strings.Split(s, "\n")
179170
minIndent := -1

pkg/cmd/root/help_test.go

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -44,47 +44,3 @@ func TestDedent(t *testing.T) {
4444
}
4545
}
4646
}
47-
48-
func Test_indent(t *testing.T) {
49-
type args struct {
50-
s string
51-
indent string
52-
}
53-
tests := []struct {
54-
name string
55-
args args
56-
want string
57-
}{
58-
{
59-
name: "empty",
60-
args: args{
61-
s: "",
62-
indent: "--",
63-
},
64-
want: "",
65-
},
66-
{
67-
name: "blank",
68-
args: args{
69-
s: "\n",
70-
indent: "--",
71-
},
72-
want: "\n",
73-
},
74-
{
75-
name: "indent",
76-
args: args{
77-
s: "one\ntwo\nthree",
78-
indent: "--",
79-
},
80-
want: "--one\n--two\n--three",
81-
},
82-
}
83-
for _, tt := range tests {
84-
t.Run(tt.name, func(t *testing.T) {
85-
if got := indent(tt.args.s, tt.args.indent); got != tt.want {
86-
t.Errorf("indent() = %q, want %q", got, tt.want)
87-
}
88-
})
89-
}
90-
}

pkg/surveyext/editor.go

Lines changed: 1 addition & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,12 @@ package surveyext
55
// To see what we extended, search through for EXTENDED comments.
66

77
import (
8-
"bytes"
9-
"io/ioutil"
108
"os"
11-
"os/exec"
129
"path/filepath"
1310
"runtime"
1411

1512
"github.com/AlecAivazis/survey/v2"
1613
"github.com/AlecAivazis/survey/v2/terminal"
17-
shellquote "github.com/kballard/go-shellquote"
1814
)
1915

2016
var (
@@ -139,64 +135,12 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
139135
continue
140136
}
141137

142-
// prepare the temp file
143-
pattern := e.FileName
144-
if pattern == "" {
145-
pattern = "survey*.txt"
146-
}
147-
f, err := ioutil.TempFile("", pattern)
148-
if err != nil {
149-
return "", err
150-
}
151-
defer os.Remove(f.Name())
152-
153-
// write utf8 BOM header
154-
// The reason why we do this is because notepad.exe on Windows determines the
155-
// encoding of an "empty" text file by the locale, for example, GBK in China,
156-
// while golang string only handles utf8 well. However, a text file with utf8
157-
// BOM header is not considered "empty" on Windows, and the encoding will then
158-
// be determined utf8 by notepad.exe, instead of GBK or other encodings.
159-
if _, err := f.Write(bom); err != nil {
160-
return "", err
161-
}
162-
163-
// write initial value
164-
if _, err := f.WriteString(initialValue); err != nil {
165-
return "", err
166-
}
167-
168-
// close the fd to prevent the editor unable to save file
169-
if err := f.Close(); err != nil {
170-
return "", err
171-
}
172-
173138
stdio := e.Stdio()
174-
175-
args, err := shellquote.Split(e.editorCommand())
176-
if err != nil {
177-
return "", err
178-
}
179-
args = append(args, f.Name())
180-
181-
// open the editor
182-
cmd := exec.Command(args[0], args[1:]...)
183-
cmd.Stdin = stdio.In
184-
cmd.Stdout = stdio.Out
185-
cmd.Stderr = stdio.Err
186-
cursor.Show()
187-
if err := cmd.Run(); err != nil {
188-
return "", err
189-
}
190-
191-
// raw is a BOM-unstripped UTF8 byte slice
192-
raw, err := ioutil.ReadFile(f.Name())
139+
text, err := Edit(e.editorCommand(), e.FileName, initialValue, stdio.In, stdio.Out, stdio.Err, cursor)
193140
if err != nil {
194141
return "", err
195142
}
196143

197-
// strip BOM header
198-
text := string(bytes.TrimPrefix(raw, bom))
199-
200144
// check length, return default value on empty
201145
if len(text) == 0 && !e.AppendDefault {
202146
return e.Default, nil

0 commit comments

Comments
 (0)