Skip to content

Commit 58415a7

Browse files
committed
ignore stale merged/closed PRs for reused branch names
1 parent dadb187 commit 58415a7

File tree

8 files changed

+671
-22
lines changed

8 files changed

+671
-22
lines changed

cmd/merge_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/github/gh-stack/internal/config"
88
"github.com/github/gh-stack/internal/git"
9+
"github.com/github/gh-stack/internal/github"
910
"github.com/github/gh-stack/internal/stack"
1011
"github.com/stretchr/testify/assert"
1112
)
@@ -32,6 +33,7 @@ func TestMerge_NoPullRequest(t *testing.T) {
3233
defer restore()
3334

3435
cfg, _, errR := config.NewTestConfig()
36+
cfg.GitHubClientOverride = &github.MockClient{}
3537
cmd := MergeCmd(cfg)
3638
cmd.SetOut(io.Discard)
3739
cmd.SetErr(io.Discard)
@@ -65,6 +67,7 @@ func TestMerge_AlreadyMerged(t *testing.T) {
6567
defer restore()
6668

6769
cfg, _, errR := config.NewTestConfig()
70+
cfg.GitHubClientOverride = &github.MockClient{}
6871
cmd := MergeCmd(cfg)
6972
cmd.SetOut(io.Discard)
7073
cmd.SetErr(io.Discard)
@@ -104,6 +107,7 @@ func TestMerge_FullyMergedStack(t *testing.T) {
104107
defer restore()
105108

106109
cfg, _, errR := config.NewTestConfig()
110+
cfg.GitHubClientOverride = &github.MockClient{}
107111
cmd := MergeCmd(cfg)
108112
cmd.SetOut(io.Discard)
109113
cmd.SetErr(io.Discard)
@@ -136,6 +140,7 @@ func TestMerge_OnTrunk(t *testing.T) {
136140
defer restore()
137141

138142
cfg, _, errR := config.NewTestConfig()
143+
cfg.GitHubClientOverride = &github.MockClient{}
139144
cmd := MergeCmd(cfg)
140145
cmd.SetOut(io.Discard)
141146
cmd.SetErr(io.Discard)
@@ -168,6 +173,19 @@ func TestMerge_NonInteractive_PrintsURL(t *testing.T) {
168173

169174
// NewTestConfig is non-interactive (piped output), so no confirm prompt.
170175
cfg, _, errR := config.NewTestConfig()
176+
cfg.GitHubClientOverride = &github.MockClient{
177+
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
178+
if number == 42 {
179+
return &github.PullRequest{
180+
Number: 42,
181+
ID: "PR_42",
182+
URL: "https://github.com/owner/repo/pull/42",
183+
State: "OPEN",
184+
}, nil
185+
}
186+
return nil, nil
187+
},
188+
}
171189
cmd := MergeCmd(cfg)
172190
cmd.SetOut(io.Discard)
173191
cmd.SetErr(io.Discard)
@@ -196,6 +214,7 @@ func TestMerge_NoArgs(t *testing.T) {
196214
defer restore()
197215

198216
cfg, _, _ := config.NewTestConfig()
217+
cfg.GitHubClientOverride = &github.MockClient{}
199218
cmd := MergeCmd(cfg)
200219
cmd.SetOut(io.Discard)
201220
cmd.SetErr(io.Discard)
@@ -229,6 +248,17 @@ func TestMerge_ByPRNumber(t *testing.T) {
229248
defer restore()
230249

231250
cfg, _, errR := config.NewTestConfig()
251+
cfg.GitHubClientOverride = &github.MockClient{
252+
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
253+
switch number {
254+
case 42:
255+
return &github.PullRequest{Number: 42, URL: "https://github.com/owner/repo/pull/42", State: "OPEN"}, nil
256+
case 43:
257+
return &github.PullRequest{Number: 43, URL: "https://github.com/owner/repo/pull/43", State: "OPEN"}, nil
258+
}
259+
return nil, nil
260+
},
261+
}
232262
cmd := MergeCmd(cfg)
233263
cmd.SetOut(io.Discard)
234264
cmd.SetErr(io.Discard)
@@ -261,6 +291,14 @@ func TestMerge_ByPRURL(t *testing.T) {
261291
defer restore()
262292

263293
cfg, _, errR := config.NewTestConfig()
294+
cfg.GitHubClientOverride = &github.MockClient{
295+
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
296+
if number == 42 {
297+
return &github.PullRequest{Number: 42, URL: "https://github.com/owner/repo/pull/42", State: "OPEN"}, nil
298+
}
299+
return nil, nil
300+
},
301+
}
264302
cmd := MergeCmd(cfg)
265303
cmd.SetOut(io.Discard)
266304
cmd.SetErr(io.Discard)
@@ -293,6 +331,14 @@ func TestMerge_ByBranchName(t *testing.T) {
293331
defer restore()
294332

295333
cfg, _, errR := config.NewTestConfig()
334+
cfg.GitHubClientOverride = &github.MockClient{
335+
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
336+
if number == 42 {
337+
return &github.PullRequest{Number: 42, URL: "https://github.com/owner/repo/pull/42", State: "OPEN"}, nil
338+
}
339+
return nil, nil
340+
},
341+
}
296342
cmd := MergeCmd(cfg)
297343
cmd.SetOut(io.Discard)
298344
cmd.SetErr(io.Discard)

cmd/submit_test.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,12 @@ func TestSubmit_SkipsMergedBranches(t *testing.T) {
196196
cfg, _, errR := config.NewTestConfig()
197197
cfg.GitHubClientOverride = &github.MockClient{
198198
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
199-
return &github.PullRequest{Number: 2, URL: "https://github.com/owner/repo/pull/2"}, nil
199+
// Only return an OPEN PR for the active branch (b2).
200+
// Merged branches (b1, b3) should have no open PR.
201+
if branch == "b2" {
202+
return &github.PullRequest{Number: 2, URL: "https://github.com/owner/repo/pull/2", State: "OPEN"}, nil
203+
}
204+
return nil, nil
200205
},
201206
}
202207
cmd := SubmitCmd(cfg)
@@ -1026,7 +1031,7 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
10261031
tmpDir := t.TempDir()
10271032
writeStackFile(t, tmpDir, s)
10281033

1029-
listStacksCalled := false
1034+
listStacksCallCount := 0
10301035
mock := newSubmitMock(tmpDir, "b1")
10311036
mock.PushFn = func(string, []string, bool, bool) error { return nil }
10321037
restore := git.SetOps(mock)
@@ -1035,8 +1040,17 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
10351040
cfg, _, errR := config.NewTestConfig()
10361041
cfg.GitHubClientOverride = &github.MockClient{
10371042
ListStacksFn: func() ([]github.RemoteStack, error) {
1038-
listStacksCalled = true
1039-
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
1043+
listStacksCallCount++
1044+
return []github.RemoteStack{{ID: 42, PullRequests: []int{10, 11}}}, nil
1045+
},
1046+
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
1047+
switch number {
1048+
case 10:
1049+
return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10", HeadRefName: "b1", State: "OPEN"}, nil
1050+
case 11:
1051+
return &github.PullRequest{Number: 11, URL: "https://github.com/o/r/pull/11", HeadRefName: "b2", State: "OPEN"}, nil
1052+
}
1053+
return nil, nil
10401054
},
10411055
FindPRForBranchFn: func(string) (*github.PullRequest, error) {
10421056
return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10"}, nil
@@ -1054,5 +1068,8 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
10541068
_, _ = io.ReadAll(errR)
10551069

10561070
assert.NoError(t, err)
1057-
assert.False(t, listStacksCalled, "ListStacks should not be called when stack ID already exists")
1071+
// ListStacks is called by syncStackPRs (remote sync), but NOT by the
1072+
// preflight check. Two syncStackPRs calls happen in submit (before and
1073+
// after PR creation), so expect exactly 2 ListStacks calls.
1074+
assert.Equal(t, 2, listStacksCallCount, "ListStacks should only be called by syncStackPRs, not by the preflight check")
10581075
}

cmd/utils.go

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/cli/go-gh/v2/pkg/prompter"
1212
"github.com/github/gh-stack/internal/config"
1313
"github.com/github/gh-stack/internal/git"
14+
"github.com/github/gh-stack/internal/github"
1415
"github.com/github/gh-stack/internal/stack"
1516
)
1617

@@ -231,27 +232,119 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac
231232
}
232233

233234
// syncStackPRs discovers and updates pull request metadata for branches in a stack.
234-
// For each branch, it queries GitHub for the most recent PR and updates the
235-
// PullRequestRef including merge status. Branches with already-merged PRs are skipped.
235+
//
236+
// When the stack has a remote ID, the stack API is the source of truth: the
237+
// authoritative PR list is fetched from the server and matched to local
238+
// branches by head branch name. PRs remain associated even if closed.
239+
//
240+
// When no remote stack exists, branch-name-based discovery is used:
241+
//
242+
// 1. No tracked PR — look for an OPEN PR by head branch name.
243+
// 2. Tracked PR (not merged) — refresh status by number; if closed,
244+
// clear the association and fall through to path 1.
245+
// 3. Tracked PR (merged) — skip; the merged state is final.
246+
//
236247
// The transient Queued flag is also populated from the API response.
237248
func syncStackPRs(cfg *config.Config, s *stack.Stack) {
238249
client, err := cfg.GitHubClient()
239250
if err != nil {
240251
return
241252
}
242253

254+
// When the stack has a remote ID, the stack API is the source of truth.
255+
if s.ID != "" {
256+
if syncStackPRsFromRemote(client, s) {
257+
return
258+
}
259+
}
260+
261+
// No remote stack (or remote sync failed) — local discovery.
243262
for i := range s.Branches {
244263
b := &s.Branches[i]
245264

246265
if b.IsMerged() {
247266
continue
248267
}
249268

250-
pr, err := client.FindAnyPRForBranch(b.Branch)
269+
if b.PullRequest != nil && b.PullRequest.Number != 0 {
270+
// Tracked PR — refresh its state.
271+
pr, err := client.FindPRByNumber(b.PullRequest.Number)
272+
if err != nil || pr == nil {
273+
continue
274+
}
275+
b.PullRequest = &stack.PullRequestRef{
276+
Number: pr.Number,
277+
ID: pr.ID,
278+
URL: pr.URL,
279+
Merged: pr.Merged,
280+
}
281+
b.Queued = pr.IsQueued()
282+
283+
// If the PR was closed (not merged), remove the association
284+
// so we fall through to the open-PR lookup below.
285+
if pr.State == "CLOSED" {
286+
b.PullRequest = nil
287+
b.Queued = false
288+
} else {
289+
continue
290+
}
291+
}
292+
293+
// No tracked PR (or just cleared) — only adopt OPEN PRs to avoid
294+
// picking up stale merged/closed PRs from a previous use of this
295+
// branch name.
296+
pr, err := client.FindPRForBranch(b.Branch)
251297
if err != nil || pr == nil {
252298
continue
253299
}
300+
b.PullRequest = &stack.PullRequestRef{
301+
Number: pr.Number,
302+
ID: pr.ID,
303+
URL: pr.URL,
304+
}
305+
b.Queued = pr.IsQueued()
306+
}
307+
}
308+
309+
// syncStackPRsFromRemote uses the stack API to sync PR state. The remote
310+
// stack's PR list is the source of truth — PRs stay associated even if
311+
// closed. Returns true if the sync succeeded, false if we should fall
312+
// back to local discovery (e.g. stack not found remotely, API error).
313+
func syncStackPRsFromRemote(client github.ClientOps, s *stack.Stack) bool {
314+
stacks, err := client.ListStacks()
315+
if err != nil {
316+
return false
317+
}
318+
319+
// Find our stack in the remote list.
320+
var remotePRNumbers []int
321+
for _, rs := range stacks {
322+
if strconv.Itoa(rs.ID) == s.ID {
323+
remotePRNumbers = rs.PullRequests
324+
break
325+
}
326+
}
327+
if remotePRNumbers == nil {
328+
return false
329+
}
254330

331+
// Fetch each remote PR's details and index by head branch name.
332+
prByBranch := make(map[string]*github.PullRequest, len(remotePRNumbers))
333+
for _, num := range remotePRNumbers {
334+
pr, err := client.FindPRByNumber(num)
335+
if err != nil || pr == nil {
336+
continue
337+
}
338+
prByBranch[pr.HeadRefName] = pr
339+
}
340+
341+
// Match remote PRs to local branches.
342+
for i := range s.Branches {
343+
b := &s.Branches[i]
344+
pr, ok := prByBranch[b.Branch]
345+
if !ok {
346+
continue
347+
}
255348
b.PullRequest = &stack.PullRequestRef{
256349
Number: pr.Number,
257350
ID: pr.ID,
@@ -260,6 +353,8 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) {
260353
}
261354
b.Queued = pr.IsQueued()
262355
}
356+
357+
return true
263358
}
264359

265360
// updateBaseSHAs refreshes the Base and Head SHAs for all active branches

0 commit comments

Comments
 (0)