Skip to content

Commit 6d8d795

Browse files
authored
branch submit: Handle CR already closed or merged (#381)
If a submitted CR is already closed or merged server-side, pretend that the CR does not exist and create a new CR instead. This is a more user-friendly behavior than permanently tying the branch name to that CR (or until the user runs untrack-retrack). In the future, we can offer to re-open closed CRs with a prompt too. Resolves #374
1 parent 8cc330f commit 6d8d795

File tree

10 files changed

+322
-12
lines changed

10 files changed

+322
-12
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
kind: Changed
2+
body: >-
3+
submit: If a CR for a branch is closed or merged,
4+
and the branch is submitted again,
5+
git-spice will now create a new CR for that branch
6+
instead of failing to update the existing CR.
7+
time: 2024-09-04T05:45:49.967536-07:00

branch_submit.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,22 @@ func (cmd *branchSubmitCmd) run(
263263
return fmt.Errorf("find change: %w", err)
264264
}
265265

266-
// TODO: If the CR is closed, we should treat it as non-existent.
267-
existingChange = change
266+
// Consider the PR only if it's open.
267+
if change.State == forge.ChangeOpen {
268+
existingChange = change
269+
} else {
270+
var state string
271+
if change.State == forge.ChangeMerged {
272+
state = "merged"
273+
} else {
274+
state = "closed"
275+
}
276+
277+
log.Infof("%v: Ignoring CR %v as it was %s: %v", cmd.Branch, change.ID, state, change.URL)
278+
// TODO:
279+
// We could offer to reopen the CR if it was closed,
280+
// but not if it was merged.
281+
}
268282
}
269283

270284
// At this point, existingChange is nil only if we need to create a new CR.

internal/forge/forge.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -272,26 +272,26 @@ type EditChangeOptions struct {
272272
// repository.
273273
type FindChangeItem struct {
274274
// ID is a unique identifier for the change.
275-
ID ChangeID
275+
ID ChangeID // required
276276

277277
// URL is the web URL at which the change can be viewed.
278-
URL string
278+
URL string // required
279279

280280
// State is the current state of the change.
281-
State ChangeState
281+
State ChangeState // required
282282

283283
// Subject is the title of the change.
284-
Subject string
284+
Subject string // required
285285

286286
// HeadHash is the hash of the commit at the top of the change.
287-
HeadHash git.Hash
287+
HeadHash git.Hash // required
288288

289289
// BaseName is the name of the base branch
290290
// that this change is proposed against.
291-
BaseName string
291+
BaseName string // required
292292

293293
// Draft is true if the change is not yet ready to be reviewed.
294-
Draft bool
294+
Draft bool // required
295295
}
296296

297297
// ChangeTemplate is a template for a new change proposal.

internal/forge/shamhub/change.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,25 @@ type shamChange struct {
5656
Owner string
5757
Repo string
5858

59+
// State is the current state of the change.
60+
// It can be open, closed, or merged.
61+
State shamChangeState
62+
63+
// Number is the numeric identifier of the change.
64+
// These increment monotonically.
5965
Number int
60-
Draft bool
61-
State shamChangeState
66+
67+
// Draft indicates that the change is not yet ready to be reviewed.
68+
Draft bool
6269

6370
Subject string
6471
Body string
6572

73+
// Name of the base branch.
6674
Base string
75+
76+
// Name of the head branch.
77+
// This is the branch that contains the changes to be merged.
6778
Head string
6879
}
6980

internal/forge/shamhub/cli.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,31 @@ func (c *Cmd) Run(ts *testscript.TestScript, neg bool, args []string) {
157157

158158
ts.Check(sh.MergeChange(req))
159159

160+
case "reject":
161+
if len(args) != 2 {
162+
ts.Fatalf("usage: shamhub reject <owner/repo> <pr>")
163+
}
164+
if sh == nil {
165+
ts.Fatalf("ShamHub not initialized")
166+
}
167+
168+
ownerRepo, prStr := args[0], args[1]
169+
owner, repo, ok := strings.Cut(ownerRepo, "/")
170+
if !ok {
171+
ts.Fatalf("invalid owner/repo: %s", ownerRepo)
172+
}
173+
pr, err := strconv.Atoi(prStr)
174+
if err != nil {
175+
ts.Fatalf("invalid PR number: %s", err)
176+
}
177+
178+
req := RejectChangeRequest{
179+
Owner: owner,
180+
Repo: repo,
181+
Number: pr,
182+
}
183+
ts.Check(sh.RejectChange(req))
184+
160185
case "register":
161186
if len(args) != 1 {
162187
ts.Fatalf("usage: shamhub register <username>")

internal/forge/shamhub/find.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,26 @@ func (f *forgeRepository) FindChangeByID(ctx context.Context, fid forge.ChangeID
142142
return nil, fmt.Errorf("find change by ID: %w", err)
143143
}
144144

145+
var state forge.ChangeState
146+
switch res.State {
147+
case "open":
148+
state = forge.ChangeOpen
149+
case "closed":
150+
if res.Merged {
151+
state = forge.ChangeMerged
152+
} else {
153+
state = forge.ChangeClosed
154+
}
155+
}
156+
145157
return &forge.FindChangeItem{
146158
ID: ChangeID(res.Number),
147159
URL: res.URL,
148160
Subject: res.Subject,
149161
HeadHash: git.Hash(res.Head.Hash),
150162
BaseName: res.Base.Name,
151163
Draft: res.Draft,
164+
State: state,
152165
}, nil
153166
}
154167

internal/forge/shamhub/reject.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package shamhub
2+
3+
import (
4+
"fmt"
5+
)
6+
7+
// RejectChangeRequest is a request to reject a change.
8+
type RejectChangeRequest struct {
9+
Owner, Repo string
10+
Number int
11+
}
12+
13+
// RejectChange closes a CR without merging it.
14+
func (sh *ShamHub) RejectChange(req RejectChangeRequest) error {
15+
if req.Owner == "" || req.Repo == "" || req.Number == 0 {
16+
return fmt.Errorf("owner, repo, and number are required")
17+
}
18+
sh.mu.Lock()
19+
defer sh.mu.Unlock()
20+
21+
var changeIdx int
22+
for idx, change := range sh.changes {
23+
if change.Owner == req.Owner && change.Repo == req.Repo && change.Number == req.Number {
24+
changeIdx = idx
25+
break
26+
}
27+
}
28+
29+
if sh.changes[changeIdx].State != shamChangeOpen {
30+
return fmt.Errorf("change %d is not open", req.Number)
31+
}
32+
33+
sh.changes[changeIdx].State = shamChangeClosed
34+
return nil
35+
}

testdata/script/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,25 @@ shamhub merge <owner/repo> <num>
132132
Merges Change Request `<num>` made in the given repository
133133
into its default branch.
134134

135+
### shamhub reject
136+
137+
```
138+
shamhub reject <owner/repo> <num>
139+
```
140+
141+
Rejects Change Request `<num>` made in the given repository.
142+
Closes the CR without merging.
143+
135144
#### shamhub dump
136145

137146
```
138147
shamhub dump changes
139148
shamhub dump change <num>
149+
shamhub dump comments
140150
```
141151

142-
Dumps a JSON blob of all changes or a single change made against ShamHub.
152+
Dumps information about all changes, a single change, or all comments
153+
to stdout.
143154

144155
#### shamhub register
145156

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# 'branch submit' gracefully handles the case
2+
# where a PR being updated is already closed.
3+
#
4+
# https://github.com/abhinav/git-spice/issues/374
5+
6+
as 'Test <[email protected]>'
7+
at '2024-09-04T05:06:07Z'
8+
9+
# setup
10+
cd repo
11+
git init
12+
git commit --allow-empty -m 'Initial commit'
13+
14+
# set up a fake GitHub remote
15+
shamhub init
16+
shamhub new origin alice/example.git
17+
shamhub register alice
18+
git push origin main
19+
20+
env SHAMHUB_USERNAME=alice
21+
gs auth login
22+
23+
git add feat1.txt
24+
gs bc -m feat1
25+
gs bs -c
26+
stderr 'Created #'
27+
28+
gs ll -a
29+
cmp stderr $WORK/golden/open.txt
30+
31+
# Close the PR
32+
shamhub reject alice/example.git 1
33+
34+
mv $WORK/extra/feat1-new.txt feat1.txt
35+
git add feat1.txt
36+
gs cc -m 'Update feature 1'
37+
38+
gs branch submit --fill
39+
stderr 'Ignoring CR #1 as it was closed'
40+
41+
gs ll -a
42+
cmp stderr $WORK/golden/resubmit.txt
43+
44+
shamhub dump changes
45+
cmpenvJSON stdout $WORK/golden/changes.json
46+
47+
-- repo/feat1.txt --
48+
Contents of feature 1
49+
50+
-- extra/feat1-new.txt --
51+
New contents of feature 1
52+
53+
-- golden/open.txt --
54+
┏━■ feat1 (#1) ◀
55+
┃ fabf34f feat1 (now)
56+
main
57+
-- golden/resubmit.txt --
58+
┏━■ feat1 (#2) ◀
59+
┃ 03fced6 Update feature 1 (now)
60+
┃ fabf34f feat1 (now)
61+
main
62+
-- golden/changes.json --
63+
[
64+
{
65+
"number": 1,
66+
"html_url": "$SHAMHUB_URL/alice/example/change/1",
67+
"state": "closed",
68+
"title": "feat1",
69+
"body": "",
70+
"base": {
71+
"ref": "main",
72+
"sha": "c649f748239dea65932c853775c6121d7cc79029"
73+
},
74+
"head": {
75+
"ref": "feat1",
76+
"sha": "03fced68a13af09f59f08474c47452dd15f3042c"
77+
}
78+
},
79+
{
80+
"number": 2,
81+
"html_url": "$SHAMHUB_URL/alice/example/change/2",
82+
"state": "open",
83+
"title": "feat1",
84+
"body": "feat1\n\nUpdate feature 1",
85+
"base": {
86+
"ref": "main",
87+
"sha": "c649f748239dea65932c853775c6121d7cc79029"
88+
},
89+
"head": {
90+
"ref": "feat1",
91+
"sha": "03fced68a13af09f59f08474c47452dd15f3042c"
92+
}
93+
}
94+
]

0 commit comments

Comments
 (0)