Skip to content

Commit 289fb2e

Browse files
authored
branch submit: Heal from external PR submissions (#141)
If a PR was submitted without using `gs`, `branch submit` will detect that and heal state. Obviates need for manually associating PRs with branches after the fact. Resolves #62
1 parent 9331441 commit 289fb2e

File tree

6 files changed

+245
-89
lines changed

6 files changed

+245
-89
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Changed
2+
body: 'branch submit: Auto-detect PRs created outside git-spice, e.g. using the GitHub UI.'
3+
time: 2024-06-01T16:21:28.52887-07:00

branch_submit.go

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,60 @@ func (cmd *branchSubmitCmd) Run(
112112
return err
113113
}
114114

115-
changes, err := forge.ListChanges(ctx, github.ListChangesOptions{
116-
State: "open",
117-
Branch: upstreamBranch,
118-
// We don't filter by base here as that may be out of date.
119-
})
120-
if err != nil {
121-
return fmt.Errorf("list changes: %w", err)
115+
// If the branch doesn't have a PR associated with it,
116+
// we'll probably need to create one,
117+
// but verify that there isn't already one open.
118+
var existingChange *github.FindChangeItem
119+
if branch.PR == 0 {
120+
changes, err := forge.FindChangesByBranch(ctx, upstreamBranch)
121+
if err != nil {
122+
return fmt.Errorf("list changes: %w", err)
123+
}
124+
125+
switch len(changes) {
126+
case 0:
127+
// No PRs found, we'll create one.
128+
case 1:
129+
existingChange = &changes[0]
130+
131+
// A PR was found, but it wasn't associated with the branch.
132+
// It was probably created manually.
133+
// We'll heal the state while we're at it.
134+
log.Infof("%v: Found existing PR %v", cmd.Name, existingChange.ID)
135+
err := store.Update(ctx, &state.UpdateRequest{
136+
Upserts: []state.UpsertRequest{
137+
{
138+
Name: cmd.Name,
139+
PR: int(existingChange.ID),
140+
},
141+
},
142+
Message: fmt.Sprintf("%v: associate existing PR", cmd.Name),
143+
})
144+
if err != nil {
145+
return fmt.Errorf("update state: %w", err)
146+
}
147+
148+
default:
149+
// GitHub doesn't allow multiple PRs for the same branch
150+
// with the same base branch.
151+
// If we get here, it means there are multiple PRs open
152+
// with different base branches.
153+
return fmt.Errorf("multiple open pull requests for %s", cmd.Name)
154+
// TODO: Ask the user to pick one and associate it with the branch.
155+
}
156+
} else {
157+
// If a PR is already associated with the branch,
158+
// fetch information about it to compare with the current state.
159+
change, err := forge.FindChangeByID(ctx, github.ChangeID(branch.PR))
160+
if err != nil {
161+
return fmt.Errorf("find change: %w", err)
162+
}
163+
// TODO: If the PR is closed, we should treat it as non-existent.
164+
existingChange = change
122165
}
123166

124-
switch len(changes) {
125-
case 0:
167+
// At this point, existingChange is nil only if we need to create a new PR.
168+
if existingChange == nil {
126169
if cmd.DryRun {
127170
log.Infof("WOULD create a pull request for %s", cmd.Name)
128171
return nil
@@ -259,10 +302,9 @@ func (cmd *branchSubmitCmd) Run(
259302
upsert.PR = int(result.ID)
260303

261304
log.Infof("Created %v: %s", result.ID, result.URL)
262-
263-
case 1:
305+
} else {
264306
// Check base and HEAD are up-to-date.
265-
pull := changes[0]
307+
pull := existingChange
266308
var updates []string
267309
if pull.HeadHash != commitHash {
268310
updates = append(updates, "push branch")
@@ -317,10 +359,6 @@ func (cmd *branchSubmitCmd) Run(
317359
}
318360

319361
log.Infof("Updated %v: %s", pull.ID, pull.URL)
320-
321-
default:
322-
// TODO: add a --pr flag to allow picking a PR?
323-
return fmt.Errorf("multiple open pull requests for %s", cmd.Name)
324362
}
325363

326364
return nil

internal/forge/github/find.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/google/go-github/v61/github"
8+
"go.abhg.dev/gs/internal/git"
9+
)
10+
11+
// FindChangeItem is a single result from searching for changes in the
12+
// repository.
13+
type FindChangeItem struct {
14+
// ID is a unique identifier for the change.
15+
ID ChangeID
16+
17+
// URL is the web URL at which the change can be viewed.
18+
URL string
19+
20+
// Subject is the title of the change.
21+
Subject string
22+
23+
// HeadHash is the hash of the commit at the top of the change.
24+
HeadHash git.Hash
25+
26+
// BaseName is the name of the base branch
27+
// that this change is proposed against.
28+
BaseName string
29+
30+
// Draft is true if the change is not yet ready to be reviewed.
31+
Draft bool
32+
}
33+
34+
// TODO: Reduce filtering options in favor of explicit queries,
35+
// e.g. "FindChangesForBranch" or "ListOpenChanges", etc.
36+
37+
// FindChangesByBranch searches for open changes with the given branch name.
38+
// Returns [ErrNotFound] if no changes are found.
39+
func (f *Forge) FindChangesByBranch(ctx context.Context, branch string) ([]FindChangeItem, error) {
40+
pulls, _, err := f.client.PullRequests.List(ctx, f.owner, f.repo, &github.PullRequestListOptions{
41+
State: "open",
42+
Head: f.owner + ":" + branch,
43+
})
44+
if err != nil {
45+
return nil, fmt.Errorf("list pull requests: %w", err)
46+
}
47+
48+
changes := make([]FindChangeItem, len(pulls))
49+
for i, pull := range pulls {
50+
changes[i] = FindChangeItem{
51+
ID: ChangeID(pull.GetNumber()),
52+
URL: pull.GetHTMLURL(),
53+
Subject: pull.GetTitle(),
54+
BaseName: pull.GetBase().GetRef(),
55+
HeadHash: git.Hash(pull.GetHead().GetSHA()),
56+
Draft: pull.GetDraft(),
57+
}
58+
}
59+
60+
return changes, nil
61+
}
62+
63+
// FindChangeByID searches for a change with the given ID.
64+
func (f *Forge) FindChangeByID(ctx context.Context, id ChangeID) (*FindChangeItem, error) {
65+
pull, _, err := f.client.PullRequests.Get(ctx, f.owner, f.repo, int(id))
66+
if err != nil {
67+
return nil, fmt.Errorf("get pull request: %w", err)
68+
}
69+
70+
return &FindChangeItem{
71+
ID: ChangeID(pull.GetNumber()),
72+
URL: pull.GetHTMLURL(),
73+
Subject: pull.GetTitle(),
74+
BaseName: pull.GetBase().GetRef(),
75+
HeadHash: git.Hash(pull.GetHead().GetSHA()),
76+
Draft: pull.GetDraft(),
77+
}, nil
78+
}

internal/forge/github/githubtest/shamhub.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ func (sh *ShamHub) apiHandler() http.Handler {
314314
mux.HandleFunc("GET /repos/{owner}/{repo}/pulls", sh.listPullRequests)
315315
mux.HandleFunc("POST /repos/{owner}/{repo}/pulls", sh.createPullRequest)
316316
mux.HandleFunc("PATCH /repos/{owner}/{repo}/pulls/{number}", sh.updatePullRequest)
317+
mux.HandleFunc("GET /repos/{owner}/{repo}/pulls/{number}", sh.getPullRequest)
317318
mux.HandleFunc("GET /repos/{owner}/{repo}/pulls/{number}/merge", sh.prIsMerged)
318319

319320
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -361,6 +362,51 @@ func (sh *ShamHub) prIsMerged(w http.ResponseWriter, r *http.Request) {
361362
}
362363
}
363364

365+
func (sh *ShamHub) getPullRequest(w http.ResponseWriter, r *http.Request) {
366+
owner, repo, numStr := r.PathValue("owner"), r.PathValue("repo"), r.PathValue("number")
367+
if owner == "" || repo == "" || numStr == "" {
368+
http.Error(w, "owner, repo, and number are required", http.StatusBadRequest)
369+
return
370+
}
371+
372+
num, err := strconv.Atoi(numStr)
373+
if err != nil {
374+
http.Error(w, err.Error(), http.StatusBadRequest)
375+
return
376+
}
377+
378+
var (
379+
got shamPR
380+
found bool
381+
)
382+
sh.mu.RLock()
383+
for _, pr := range sh.pulls {
384+
if pr.Owner == owner && pr.Repo == repo && pr.Number == num {
385+
got = pr
386+
found = true
387+
break
388+
}
389+
}
390+
sh.mu.RUnlock()
391+
392+
if !found {
393+
http.Error(w, "pull request not found", http.StatusNotFound)
394+
return
395+
}
396+
397+
ghpr, err := sh.toGitHubPR(&got)
398+
if err != nil {
399+
http.Error(w, err.Error(), http.StatusInternalServerError)
400+
return
401+
}
402+
403+
enc := json.NewEncoder(w)
404+
enc.SetIndent("", " ")
405+
if err := enc.Encode(ghpr); err != nil {
406+
http.Error(w, err.Error(), http.StatusInternalServerError)
407+
}
408+
}
409+
364410
func (sh *ShamHub) listPullRequests(w http.ResponseWriter, r *http.Request) {
365411
type prMatcher func(*shamPR) bool
366412

internal/forge/github/list.go

Lines changed: 0 additions & 73 deletions
This file was deleted.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# 'branch submit' should detect a PR created
2+
# outside of the branch submit command.
3+
4+
as 'Test <[email protected]>'
5+
at '2024-05-18T13:57:12Z'
6+
7+
# setup
8+
cd repo
9+
git init
10+
git commit --allow-empty -m 'Initial commit'
11+
12+
# set up a fake GitHub remote
13+
gh-init
14+
gh-add-remote origin alice/example.git
15+
git push origin main
16+
17+
# create a new branch and submit it
18+
git add feature1.txt
19+
gs bc -m 'Add feature1' feature1
20+
gs branch submit --fill
21+
stderr 'Created #'
22+
23+
# forget all state, and re-track the branch
24+
gs repo init --reset --trunk=main --remote=origin
25+
gs branch track --base=main feature1
26+
27+
# If we now commit to the branch and then submit,
28+
# the system should detect that a PR already exists,
29+
# and update that instead.
30+
cp $WORK/extra/feature1-update.txt feature1.txt
31+
git add feature1.txt
32+
git commit -m 'update feature1'
33+
34+
gs branch submit
35+
stderr 'feature1: Found existing PR #'
36+
stderr 'Updated #'
37+
38+
gh-dump-pull
39+
cmpenvJSON stdout $WORK/golden/update.json
40+
41+
-- repo/feature1.txt --
42+
Contents of feature1
43+
44+
-- extra/feature1-update.txt --
45+
New contents of feature1
46+
47+
-- golden/update.json --
48+
[
49+
{
50+
"number": 1,
51+
"state": "open",
52+
"title": "Add feature1",
53+
"body": "",
54+
"html_url": "$GITHUB_URL/alice/example/pull/1",
55+
"head": {
56+
"ref": "feature1",
57+
"sha": "b805a8b9545d71929cc128fc81b0d86bb2def9ed"
58+
},
59+
"base": {
60+
"ref": "main",
61+
"sha": "9df31764fb4252f719c92d53fae05a766f019a17"
62+
}
63+
}
64+
]

0 commit comments

Comments
 (0)