Skip to content

Commit cdbeb44

Browse files
authored
branch submit: Respect PR templates (#156)
Picks up the first available PR template, if any and fills the default PR body with it (along with the commit messages). Allows for users to fill in details, and adjust the PR message before submission. Caveat: This will perform a PR template look up on every PR submission. For a 'stack submit', that means N lookups. We can probably optimize this by caching the PR template between submissions in the same command invocation, or by generating some sort of hash key based on the .github/ directory and store it in the data store. Resolves #87
1 parent 6790887 commit cdbeb44

File tree

10 files changed

+360
-10
lines changed

10 files changed

+360
-10
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Added
2+
body: 'branch submit: Populate default PR message with PR template, if found.'
3+
time: 2024-06-04T20:12:08.961368-07:00

branch_submit.go

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"strings"
8+
"time"
89

910
"github.com/charmbracelet/log"
1011
"go.abhg.dev/gs/internal/forge"
@@ -175,6 +176,26 @@ func (cmd *branchSubmitCmd) Run(
175176
return nil
176177
}
177178

179+
// Fetch the template while we're figuring out the default PR
180+
// title and body.
181+
changeTemplate := make(chan *forge.ChangeTemplate, 1)
182+
go func() {
183+
defer close(changeTemplate)
184+
185+
ctx, cancel := context.WithTimeout(ctx, time.Second)
186+
defer cancel()
187+
188+
templates, err := remoteRepo.ListChangeTemplates(ctx)
189+
if err != nil {
190+
log.Warn("Could not list change templates", "error", err)
191+
return
192+
}
193+
194+
if len(templates) > 0 {
195+
changeTemplate <- templates[0]
196+
}
197+
}()
198+
178199
msgs, err := repo.CommitMessageRange(ctx, cmd.Name, branch.Base)
179200
if err != nil {
180201
return fmt.Errorf("list commits: %w", err)
@@ -183,30 +204,31 @@ func (cmd *branchSubmitCmd) Run(
183204
return errors.New("no commits to submit")
184205
}
185206

186-
var defaultTitle, defaultBody string
207+
var (
208+
defaultTitle string
209+
defaultBody strings.Builder
210+
)
187211
if len(msgs) == 1 {
188212
// If there's only one commit,
189213
// just the body will be the default body.
190214
defaultTitle = msgs[0].Subject
191-
defaultBody = msgs[0].Body
215+
defaultBody.WriteString(msgs[0].Body)
192216
} else {
193217
// Otherwise, we'll concatenate all the messages.
194218
// The revisions are in reverse order,
195219
// so we'll want to iterate in reverse.
196-
var body strings.Builder
197220
defaultTitle = msgs[len(msgs)-1].Subject
198221
for i := len(msgs) - 1; i >= 0; i-- {
199222
msg := msgs[i]
200-
if body.Len() > 0 {
201-
body.WriteString("\n\n")
223+
if defaultBody.Len() > 0 {
224+
defaultBody.WriteString("\n\n")
202225
}
203-
body.WriteString(msg.Subject)
226+
defaultBody.WriteString(msg.Subject)
204227
if msg.Body != "" {
205-
body.WriteString("\n\n")
206-
body.WriteString(msg.Body)
228+
defaultBody.WriteString("\n\n")
229+
defaultBody.WriteString(msg.Body)
207230
}
208231
}
209-
defaultBody = body.String()
210232
}
211233

212234
var fields []ui.Field
@@ -226,7 +248,12 @@ func (cmd *branchSubmitCmd) Run(
226248
}
227249

228250
if cmd.Body == "" {
229-
cmd.Body = defaultBody
251+
if tmpl, ok := <-changeTemplate; ok {
252+
defaultBody.WriteString("\n\n")
253+
defaultBody.WriteString(tmpl.Body)
254+
}
255+
256+
cmd.Body = defaultBody.String()
230257
body := ui.NewOpenEditor().
231258
WithValue(&cmd.Body).
232259
WithTitle("Body").

internal/forge/forge.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ type Repository interface {
9797
FindChangesByBranch(ctx context.Context, branch string) ([]*FindChangeItem, error)
9898
FindChangeByID(ctx context.Context, id ChangeID) (*FindChangeItem, error)
9999
IsMerged(ctx context.Context, id ChangeID) (bool, error)
100+
101+
// ListChangeTemplates returns templates defined in the repository
102+
// for new change proposals.
103+
//
104+
// Returns an empty list if no templates are found.
105+
ListChangeTemplates(context.Context) ([]*ChangeTemplate, error)
100106
}
101107

102108
// SubmitChangeRequest is a request to submit a new change in a repository.
@@ -162,3 +168,14 @@ type FindChangeItem struct {
162168
// Draft is true if the change is not yet ready to be reviewed.
163169
Draft bool
164170
}
171+
172+
// ChangeTemplate is a template for a new change proposal.
173+
type ChangeTemplate struct {
174+
// Filename is the name of the template file.
175+
//
176+
// This is NOT a path.
177+
Filename string
178+
179+
// Body is the content of the template file.
180+
Body string
181+
}

internal/forge/github/integration_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,33 @@ func TestIntegration_Repository_IsMerged(t *testing.T) {
203203
assert.True(t, ok)
204204
})
205205
}
206+
207+
func TestIntegration_Repository_ListChangeTemplates(t *testing.T) {
208+
ctx := context.Background()
209+
210+
t.Run("absent", func(t *testing.T) {
211+
rec := newRecorder(t, t.Name())
212+
ghc := githubv4.NewClient(rec.GetDefaultClient())
213+
repo, err := github.NewRepository(ctx, "abhinav", "git-spice", logtest.New(t), ghc, _gitSpiceRepoID)
214+
require.NoError(t, err)
215+
216+
templates, err := repo.ListChangeTemplates(ctx)
217+
require.NoError(t, err)
218+
assert.Empty(t, templates)
219+
})
220+
221+
t.Run("present", func(t *testing.T) {
222+
rec := newRecorder(t, t.Name())
223+
ghc := githubv4.NewClient(rec.GetDefaultClient())
224+
repo, err := github.NewRepository(ctx, "golang", "go", logtest.New(t), ghc, nil)
225+
require.NoError(t, err)
226+
227+
templates, err := repo.ListChangeTemplates(ctx)
228+
require.NoError(t, err)
229+
require.Len(t, templates, 1)
230+
231+
template := templates[0]
232+
assert.Equal(t, "PULL_REQUEST_TEMPLATE", template.Filename)
233+
assert.NotEmpty(t, template.Body)
234+
})
235+
}

internal/forge/github/template.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package github
2+
3+
import (
4+
"context"
5+
6+
"github.com/shurcooL/githubv4"
7+
"go.abhg.dev/gs/internal/forge"
8+
)
9+
10+
// ListChangeTemplates returns PR templates defined in the repository.
11+
func (r *Repository) ListChangeTemplates(ctx context.Context) ([]*forge.ChangeTemplate, error) {
12+
var q struct {
13+
Repository struct {
14+
PullRequestTemplates []struct {
15+
Filename githubv4.String `graphql:"filename"`
16+
Body githubv4.String `graphql:"body"`
17+
} `graphql:"pullRequestTemplates"`
18+
} `graphql:"repository(owner: $owner, name: $name)"`
19+
}
20+
21+
if err := r.client.Query(ctx, &q, map[string]any{
22+
"owner": githubv4.String(r.owner),
23+
"name": githubv4.String(r.repo),
24+
}); err != nil {
25+
return nil, err
26+
}
27+
28+
out := make([]*forge.ChangeTemplate, 0, len(q.Repository.PullRequestTemplates))
29+
for _, t := range q.Repository.PullRequestTemplates {
30+
if t.Body != "" {
31+
out = append(out, &forge.ChangeTemplate{
32+
Filename: string(t.Filename),
33+
Body: string(t.Body),
34+
})
35+
}
36+
}
37+
38+
return out, nil
39+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
version: 2
3+
interactions:
4+
- id: 0
5+
request:
6+
proto: HTTP/1.1
7+
proto_major: 1
8+
proto_minor: 1
9+
content_length: 175
10+
transfer_encoding: []
11+
trailer: {}
12+
host: api.github.com
13+
remote_addr: ""
14+
request_uri: ""
15+
body: |
16+
{"query":"query($name:String!$owner:String!){repository(owner: $owner, name: $name){pullRequestTemplates{filename,body}}}","variables":{"name":"git-spice","owner":"abhinav"}}
17+
form: {}
18+
headers:
19+
Content-Type:
20+
- application/json
21+
url: https://api.github.com/graphql
22+
method: POST
23+
response:
24+
proto: HTTP/2.0
25+
proto_major: 2
26+
proto_minor: 0
27+
transfer_encoding: []
28+
trailer: {}
29+
content_length: -1
30+
uncompressed: true
31+
body: '{"data":{"repository":{"pullRequestTemplates":[]}}}'
32+
headers:
33+
Content-Type:
34+
- application/json; charset=utf-8
35+
status: 200 OK
36+
code: 200
37+
duration: 300.745709ms
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
version: 2
3+
interactions:
4+
- id: 0
5+
request:
6+
proto: HTTP/1.1
7+
proto_major: 1
8+
proto_minor: 1
9+
content_length: 134
10+
transfer_encoding: []
11+
trailer: {}
12+
host: api.github.com
13+
remote_addr: ""
14+
request_uri: ""
15+
body: |
16+
{"query":"query($owner:String!$repo:String!){repository(owner: $owner, name: $repo){id}}","variables":{"owner":"golang","repo":"go"}}
17+
form: {}
18+
headers:
19+
Content-Type:
20+
- application/json
21+
url: https://api.github.com/graphql
22+
method: POST
23+
response:
24+
proto: HTTP/2.0
25+
proto_major: 2
26+
proto_minor: 0
27+
transfer_encoding: []
28+
trailer: {}
29+
content_length: -1
30+
uncompressed: true
31+
body: '{"data":{"repository":{"id":"MDEwOlJlcG9zaXRvcnkyMzA5Njk1OQ=="}}}'
32+
headers:
33+
Content-Type:
34+
- application/json; charset=utf-8
35+
status: 200 OK
36+
code: 200
37+
duration: 198.453709ms
38+
- id: 1
39+
request:
40+
proto: HTTP/1.1
41+
proto_major: 1
42+
proto_minor: 1
43+
content_length: 167
44+
transfer_encoding: []
45+
trailer: {}
46+
host: api.github.com
47+
remote_addr: ""
48+
request_uri: ""
49+
body: |
50+
{"query":"query($name:String!$owner:String!){repository(owner: $owner, name: $name){pullRequestTemplates{filename,body}}}","variables":{"name":"go","owner":"golang"}}
51+
form: {}
52+
headers:
53+
Content-Type:
54+
- application/json
55+
url: https://api.github.com/graphql
56+
method: POST
57+
response:
58+
proto: HTTP/2.0
59+
proto_major: 2
60+
proto_minor: 0
61+
transfer_encoding: []
62+
trailer: {}
63+
content_length: -1
64+
uncompressed: true
65+
body: '{"data":{"repository":{"pullRequestTemplates":[{"filename":"PULL_REQUEST_TEMPLATE","body":"This PR will be imported into Gerrit with the title and first\ncomment (this text) used to generate the subject and body of\nthe Gerrit change.\n\n**Please ensure you adhere to every item in this list.**\n\nMore info can be found at https://github.com/golang/go/wiki/CommitMessage\n\n+ The PR title is formatted as follows: `net/http: frob the quux before blarfing`\n + The package name goes before the colon\n + The part after the colon uses the verb tense + phrase that completes the blank in,\n \"This change modifies Go to ___________\"\n + Lowercase verb after the colon\n + No trailing period\n + Keep the title as short as possible. ideally under 76 characters or shorter\n+ No Markdown\n+ The first PR comment (this one) is wrapped at 76 characters, unless it''s\n really needed (ASCII art, table, or long link)\n+ If there is a corresponding issue, add either `Fixes #1234` or `Updates #1234`\n (the latter if this is not a complete fix) to this comment\n+ If referring to a repo other than `golang/go` you can use the\n `owner/repo#issue_number` syntax: `Fixes golang/tools#1234`\n+ We do not use Signed-off-by lines in Go. Please don''t add them.\n Our Gerrit server & GitHub bots enforce CLA compliance instead.\n+ Delete these instructions once you have read and applied them\n"}]}}}'
66+
headers:
67+
Content-Type:
68+
- application/json; charset=utf-8
69+
status: 200 OK
70+
code: 200
71+
duration: 189.032875ms

internal/forge/shamhub/forge.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,24 @@ func (f *forgeRepository) IsMerged(ctx context.Context, id forge.ChangeID) (bool
164164
return res.Merged, nil
165165
}
166166

167+
func (f *forgeRepository) ListChangeTemplates(ctx context.Context) ([]*forge.ChangeTemplate, error) {
168+
u := f.apiURL.JoinPath(f.owner, f.repo, "change-template")
169+
var res changeTemplateResponse
170+
if err := f.client.Get(ctx, u.String(), &res); err != nil {
171+
return nil, fmt.Errorf("lookup change body template: %w", err)
172+
}
173+
174+
out := make([]*forge.ChangeTemplate, len(res))
175+
for i, t := range res {
176+
out[i] = &forge.ChangeTemplate{
177+
Filename: t.Filename,
178+
Body: t.Body,
179+
}
180+
}
181+
182+
return out, nil
183+
}
184+
167185
type jsonHTTPClient struct {
168186
log *log.Logger
169187
client interface {

internal/forge/shamhub/shamhub.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ func (sh *ShamHub) apiHandler() http.Handler {
330330
mux.HandleFunc("GET /{owner}/{repo}/change/{number}", sh.handleGetChange)
331331
mux.HandleFunc("PATCH /{owner}/{repo}/change/{number}", sh.handleEditChange)
332332
mux.HandleFunc("GET /{owner}/{repo}/change/{number}/merged", sh.handleIsMerged)
333+
mux.HandleFunc("GET /{owner}/{repo}/change-template", sh.handleChangeTemplate)
333334
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
334335
sh.log.Errorf("Unexpected request: %s %s", r.Method, r.URL.Path)
335336
http.Error(w, "not found", http.StatusNotFound)
@@ -580,6 +581,44 @@ func (sh *ShamHub) handleIsMerged(w http.ResponseWriter, r *http.Request) {
580581
}
581582
}
582583

584+
type changeTemplateResponse []*changeTemplate
585+
586+
type changeTemplate struct {
587+
Filename string `json:"filename,omitempty"`
588+
Body string `json:"body,omitempty"`
589+
}
590+
591+
func (sh *ShamHub) handleChangeTemplate(w http.ResponseWriter, r *http.Request) {
592+
owner, repo := r.PathValue("owner"), r.PathValue("repo")
593+
if owner == "" || repo == "" {
594+
http.Error(w, "owner, and repo are required", http.StatusBadRequest)
595+
return
596+
}
597+
598+
// If the repository has a .shamhub/CHANGE_TEMPLATE.md file,
599+
// that's the template to use.
600+
logw, flush := ioutil.LogWriter(sh.log, log.DebugLevel)
601+
defer flush()
602+
603+
cmd := exec.Command(sh.gitExe, "cat-file", "-p", "HEAD:.shamhub/CHANGE_TEMPLATE.md")
604+
cmd.Dir = sh.repoDir(owner, repo)
605+
cmd.Stderr = logw
606+
607+
var res changeTemplateResponse
608+
if out, err := cmd.Output(); err == nil {
609+
res = append(res, &changeTemplate{
610+
Filename: "CHANGE_TEMPLATE.md",
611+
Body: strings.TrimSpace(string(out)) + "\n",
612+
})
613+
}
614+
615+
enc := json.NewEncoder(w)
616+
enc.SetIndent("", " ")
617+
if err := enc.Encode(res); err != nil {
618+
http.Error(w, err.Error(), http.StatusInternalServerError)
619+
}
620+
}
621+
583622
// shamChangeState records the state of a Change.
584623
type shamChangeState int
585624

0 commit comments

Comments
 (0)