Skip to content

Commit 31b8bbb

Browse files
committed
fix: tighten landing pr recovery paths
1 parent 9fba65f commit 31b8bbb

2 files changed

Lines changed: 200 additions & 3 deletions

File tree

internal/cmd/root.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1985,7 +1985,9 @@ func buildCloseoutPlan(runtime *stackruntime.Runtime, state store.RepoState, lan
19851985
record, tracked := state.Branches[branch]
19861986
if tracked {
19871987
record, err = refreshTrackedPR(runtime, state, branch, record)
1988-
if err == nil && record.PR.Number > 0 {
1988+
if err != nil {
1989+
plan.FollowUps = append(plan.FollowUps, fmt.Sprintf("source PR for %s could not be refreshed: %v", branch, err))
1990+
} else if record.PR.Number > 0 {
19891991
pr := record.PR
19901992
branchPlan.PR = &pr
19911993
}
@@ -2276,7 +2278,25 @@ func resolveLandingPRs(runtime *stackruntime.Runtime, landingBranch string, land
22762278
}
22772279
return []store.PullRequest{pr}, nil
22782280
}
2279-
return runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch)
2281+
prs, err := runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch)
2282+
if err != nil {
2283+
return nil, err
2284+
}
2285+
2286+
open := make([]store.PullRequest, 0, len(prs))
2287+
for _, pr := range prs {
2288+
if pr.State == "OPEN" {
2289+
open = append(open, pr)
2290+
}
2291+
}
2292+
if len(open) == 1 {
2293+
return open, nil
2294+
}
2295+
if len(open) > 1 {
2296+
return open, nil
2297+
}
2298+
2299+
return prs, nil
22802300
}
22812301

22822302
func parsePRNumbers(values []string) ([]int, error) {

internal/cmd/root_test.go

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1957,7 +1957,7 @@ func TestCloseoutRequiresExplicitResolutionForAmbiguousLandingPRs(t *testing.T)
19571957
"headRefName": "stack/discovery-core",
19581958
"baseRefName": "main",
19591959
"headRefOid": "`+landingHead+`",
1960-
"state": "MERGED",
1960+
"state": "OPEN",
19611961
"isDraft": false
19621962
}
19631963
},
@@ -1970,6 +1970,95 @@ func TestCloseoutRequiresExplicitResolutionForAmbiguousLandingPRs(t *testing.T)
19701970
}
19711971
}
19721972

1973+
func TestCloseoutSurfacesTrackedSourcePRRefreshFailures(t *testing.T) {
1974+
repo := testutil.SetupGitRepo(t)
1975+
testutil.Run(t, repo, "git", "switch", "-c", "feature/a")
1976+
testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n")
1977+
testutil.Run(t, repo, "git", "add", "feature-a.txt")
1978+
testutil.Run(t, repo, "git", "commit", "-m", "add feature a")
1979+
1980+
testutil.Run(t, repo, "git", "switch", "main")
1981+
testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core")
1982+
testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n")
1983+
testutil.Run(t, repo, "git", "add", "landing.txt")
1984+
testutil.Run(t, repo, "git", "commit", "-m", "landing")
1985+
landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD"))
1986+
1987+
runtime := newTestRuntime(repo)
1988+
state := store.RepoState{
1989+
Version: 1,
1990+
Repo: "hack-dance/stack",
1991+
DefaultRemote: "origin",
1992+
Trunk: "main",
1993+
Branches: map[string]store.BranchRecord{
1994+
"feature/a": {
1995+
ParentBranch: "main",
1996+
RemoteName: "origin",
1997+
PR: store.PullRequest{
1998+
Number: 1,
1999+
HeadRefName: "feature/a",
2000+
BaseRefName: "main",
2001+
LastSeenHeadOID: strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "feature/a")),
2002+
State: "OPEN",
2003+
},
2004+
},
2005+
},
2006+
Landings: map[string]store.LandingRecord{
2007+
"stack/discovery-core": {
2008+
BaseBranch: "main",
2009+
SourceBranches: []string{"feature/a"},
2010+
CreatedAt: "2026-03-26T18:40:00Z",
2011+
},
2012+
},
2013+
Verifications: map[string][]store.VerificationRecord{
2014+
"stack/discovery-core": {
2015+
{
2016+
CheckType: "manual",
2017+
Identifier: "check",
2018+
Passed: true,
2019+
HeadOID: landingHead,
2020+
RecordedAt: "2026-03-26T18:41:00Z",
2021+
},
2022+
},
2023+
},
2024+
}
2025+
if err := runtime.Store.WriteState(runtime.Context, state); err != nil {
2026+
t.Fatalf("write state: %v", err)
2027+
}
2028+
2029+
ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main")
2030+
t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath)
2031+
t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath)
2032+
t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH"))
2033+
2034+
writeGHState(t, ghStub.StatePath, `{
2035+
"repo": {
2036+
"nameWithOwner": "hack-dance/stack",
2037+
"url": "https://github.com/hack-dance/stack",
2038+
"defaultBranchRef": { "name": "main" }
2039+
},
2040+
"prs": {
2041+
"9": {
2042+
"id": "PR_9",
2043+
"number": 9,
2044+
"url": "https://example.com/hack-dance/stack/pull/9",
2045+
"repo": "hack-dance/stack",
2046+
"headRefName": "stack/discovery-core",
2047+
"baseRefName": "main",
2048+
"headRefOid": "`+landingHead+`",
2049+
"state": "MERGED",
2050+
"isDraft": false
2051+
}
2052+
},
2053+
"next_number": 10
2054+
}`)
2055+
2056+
output := executeCommand(t, runtime, "closeout", "stack/discovery-core")
2057+
if !strings.Contains(output, `source PR for feature/a could not be refreshed: tracked PR #1 for "feature/a" could not be loaded`) {
2058+
t.Fatalf("expected source PR refresh failure guidance, got %q", output)
2059+
}
2060+
}
2061+
19732062
func TestSupersedeRecordsMetadataAndCommentsOriginalPRs(t *testing.T) {
19742063
repo := testutil.SetupGitRepo(t)
19752064
testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core")
@@ -2685,6 +2774,94 @@ func TestQueueAllowsLandingBranchAndPrintsCloseoutGuidance(t *testing.T) {
26852774
}
26862775
}
26872776

2777+
func TestQueueLandingBranchPrefersUniqueOpenPRWhenHistoryExists(t *testing.T) {
2778+
repo, runtime, _ := setupTrackedStackRepo(t)
2779+
testutil.Run(t, repo, "git", "switch", "main")
2780+
testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core")
2781+
testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n")
2782+
testutil.Run(t, repo, "git", "add", "landing.txt")
2783+
testutil.Run(t, repo, "git", "commit", "-m", "landing")
2784+
testutil.Run(t, repo, "git", "push", "-u", "origin", "stack/discovery-core")
2785+
landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD"))
2786+
2787+
state := store.RepoState{
2788+
Version: 1,
2789+
Repo: "hack-dance/stack",
2790+
DefaultRemote: "origin",
2791+
Trunk: "main",
2792+
Branches: map[string]store.BranchRecord{},
2793+
Landings: map[string]store.LandingRecord{
2794+
"stack/discovery-core": {
2795+
BaseBranch: "main",
2796+
SourceBranches: []string{},
2797+
CreatedAt: "2026-03-26T18:40:00Z",
2798+
},
2799+
},
2800+
Verifications: map[string][]store.VerificationRecord{
2801+
"stack/discovery-core": {
2802+
{
2803+
CheckType: "manual",
2804+
Identifier: "check",
2805+
Passed: true,
2806+
HeadOID: landingHead,
2807+
RecordedAt: "2026-03-26T18:41:00Z",
2808+
},
2809+
},
2810+
},
2811+
}
2812+
if err := runtime.Store.WriteState(runtime.Context, state); err != nil {
2813+
t.Fatalf("write state: %v", err)
2814+
}
2815+
2816+
ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main")
2817+
t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath)
2818+
t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath)
2819+
t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH"))
2820+
2821+
writeGHState(t, ghStub.StatePath, `{
2822+
"repo": {
2823+
"nameWithOwner": "hack-dance/stack",
2824+
"url": "https://github.com/hack-dance/stack",
2825+
"defaultBranchRef": { "name": "main" }
2826+
},
2827+
"prs": {
2828+
"9": {
2829+
"id": "PR_9",
2830+
"number": 9,
2831+
"url": "https://example.com/hack-dance/stack/pull/9",
2832+
"repo": "hack-dance/stack",
2833+
"headRefName": "stack/discovery-core",
2834+
"baseRefName": "main",
2835+
"headRefOid": "`+landingHead+`",
2836+
"state": "OPEN",
2837+
"isDraft": false
2838+
},
2839+
"10": {
2840+
"id": "PR_10",
2841+
"number": 10,
2842+
"url": "https://example.com/hack-dance/stack/pull/10",
2843+
"repo": "hack-dance/stack",
2844+
"headRefName": "stack/discovery-core",
2845+
"baseRefName": "main",
2846+
"headRefOid": "`+landingHead+`",
2847+
"state": "MERGED",
2848+
"isDraft": false
2849+
}
2850+
},
2851+
"next_number": 11
2852+
}`)
2853+
2854+
output := executeCommand(t, runtime, "queue", "stack/discovery-core", "--yes")
2855+
if !strings.Contains(output, "PR #9") {
2856+
t.Fatalf("expected queue output to use the unique open landing PR, got %q", output)
2857+
}
2858+
2859+
log := readFile(t, ghStub.LogPath)
2860+
if !strings.Contains(log, "pr merge 9 --auto --merge --match-head-commit "+landingHead) {
2861+
t.Fatalf("expected queue to merge open landing PR #9, got %q", log)
2862+
}
2863+
}
2864+
26882865
func TestQueueRejectsLandingBranchWithStaleVerification(t *testing.T) {
26892866
repo := testutil.SetupGitRepo(t)
26902867
remote := filepath.Join(t.TempDir(), "remote.git")

0 commit comments

Comments
 (0)