Files
Sameen Karim dcb5885053 Skip pushing branches with queued PRs
Detect merge queue status via the GitHub GraphQL API's mergeQueueEntry
field and temporarily skip queued branches in push, sync, and submit
commands. Unlike merged state (which is persisted permanently), queued
state is transient — held in-memory only via a json:"-" tagged field
on BranchRef. Each command run re-checks queue status from the API, so
if a PR is ejected from the queue it becomes active again on next run.

Changes:
- Add MergeQueueEntry to PullRequest GraphQL struct and PRDetails
- Add IsQueued()/IsSkipped()/QueuedBranches() to stack model
- Update ActiveBranches() family to exclude queued branches
- Skip queued branches in push, sync (rebase + push), and submit
- Add queued icon, style, and QUEUED state label in TUI view
- Add comprehensive tests for queued state handling

Closes github/pull-requests#24019

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 08:24:36 -04:00

608 lines
14 KiB
Go

package stack
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func makeStack(trunk string, branches ...string) Stack {
s := Stack{Trunk: BranchRef{Branch: trunk}}
for _, b := range branches {
s.Branches = append(s.Branches, BranchRef{Branch: b})
}
return s
}
func makeMergedBranch(name string, prNum int) BranchRef {
return BranchRef{Branch: name, PullRequest: &PullRequestRef{Number: prNum, Merged: true}}
}
// --- ActiveBaseBranch: skipping merged ancestors for rebase ---
func TestActiveBaseBranch(t *testing.T) {
tests := []struct {
name string
stack Stack
branch string
expected string
}{
{
name: "no merged ancestors returns previous branch",
stack: Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
},
branch: "b3",
expected: "b2",
},
{
name: "immediate ancestor merged skips to next non-merged",
stack: Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
{Branch: "b1"},
makeMergedBranch("b2", 10),
{Branch: "b3"},
},
},
branch: "b3",
expected: "b1",
},
{
name: "all ancestors merged returns trunk",
stack: Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
makeMergedBranch("b2", 2),
{Branch: "b3"},
},
},
branch: "b3",
expected: "main",
},
{
name: "first branch always returns trunk",
stack: Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{{Branch: "b1"}},
},
branch: "b1",
expected: "main",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.stack.ActiveBaseBranch(tt.branch))
})
}
}
// --- ActiveBranches / MergedBranches partition ---
func TestActiveBranches_And_MergedBranches(t *testing.T) {
t.Run("all active", func(t *testing.T) {
s := makeStack("main", "b1", "b2", "b3")
assert.Len(t, s.ActiveBranches(), 3)
assert.Empty(t, s.MergedBranches())
})
t.Run("some merged", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
{Branch: "b2"},
makeMergedBranch("b3", 3),
},
}
active := s.ActiveBranches()
merged := s.MergedBranches()
assert.Len(t, active, 1)
assert.Equal(t, "b2", active[0].Branch)
assert.Len(t, merged, 2)
assert.Equal(t, "b1", merged[0].Branch)
assert.Equal(t, "b3", merged[1].Branch)
})
t.Run("all merged", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
makeMergedBranch("b2", 2),
},
}
assert.Empty(t, s.ActiveBranches())
assert.Len(t, s.MergedBranches(), 2)
})
}
// --- IsFullyMerged: blocks add on fully-merged stacks ---
func TestIsFullyMerged(t *testing.T) {
t.Run("all merged", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
makeMergedBranch("b2", 2),
},
}
assert.True(t, s.IsFullyMerged())
})
t.Run("some active", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
{Branch: "b2"},
},
}
assert.False(t, s.IsFullyMerged())
})
t.Run("empty branches is not fully merged", func(t *testing.T) {
s := Stack{Trunk: BranchRef{Branch: "main"}}
assert.False(t, s.IsFullyMerged())
})
}
// --- FirstActiveBranchIndex: navigation ---
func TestFirstActiveBranchIndex(t *testing.T) {
t.Run("first is active", func(t *testing.T) {
s := makeStack("main", "b1", "b2")
assert.Equal(t, 0, s.FirstActiveBranchIndex())
})
t.Run("first two merged third active", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
makeMergedBranch("b2", 2),
{Branch: "b3"},
},
}
assert.Equal(t, 2, s.FirstActiveBranchIndex())
})
t.Run("all merged", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
makeMergedBranch("b2", 2),
},
}
assert.Equal(t, -1, s.FirstActiveBranchIndex())
})
}
// --- ActiveBranchIndices: navigation ---
func TestActiveBranchIndices(t *testing.T) {
t.Run("all active", func(t *testing.T) {
s := makeStack("main", "b1", "b2", "b3")
assert.Equal(t, []int{0, 1, 2}, s.ActiveBranchIndices())
})
t.Run("some merged", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
{Branch: "b2"},
makeMergedBranch("b3", 3),
{Branch: "b4"},
},
}
assert.Equal(t, []int{1, 3}, s.ActiveBranchIndices())
})
t.Run("all merged", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
makeMergedBranch("b2", 2),
},
}
assert.Empty(t, s.ActiveBranchIndices())
})
}
// --- Load / Save round-trip persistence ---
func TestLoad_Save_RoundTrip(t *testing.T) {
t.Run("save and reload preserves all fields", func(t *testing.T) {
dir := t.TempDir()
original := &StackFile{
Repository: "owner/repo",
Stacks: []Stack{
{
ID: "s1",
Prefix: "feat",
Trunk: BranchRef{Branch: "main", Head: "abc123"},
Branches: []BranchRef{
{Branch: "b1", Head: "def456", Base: "abc123"},
{Branch: "b2", PullRequest: &PullRequestRef{Number: 42, ID: "PR_id", URL: "https://example.com", Merged: true}},
},
},
},
}
require.NoError(t, Save(dir, original))
loaded, err := Load(dir)
require.NoError(t, err)
assert.Equal(t, schemaVersion, loaded.SchemaVersion)
assert.Equal(t, original.Repository, loaded.Repository)
require.Len(t, loaded.Stacks, 1)
s := loaded.Stacks[0]
assert.Equal(t, "s1", s.ID)
assert.Equal(t, "feat", s.Prefix)
assert.Equal(t, "main", s.Trunk.Branch)
assert.Equal(t, "abc123", s.Trunk.Head)
require.Len(t, s.Branches, 2)
assert.Equal(t, "b1", s.Branches[0].Branch)
assert.Equal(t, "def456", s.Branches[0].Head)
assert.Equal(t, "abc123", s.Branches[0].Base)
require.NotNil(t, s.Branches[1].PullRequest)
assert.Equal(t, 42, s.Branches[1].PullRequest.Number)
assert.True(t, s.Branches[1].PullRequest.Merged)
})
t.Run("missing file returns empty stack file", func(t *testing.T) {
dir := t.TempDir()
sf, err := Load(dir)
require.NoError(t, err)
assert.Equal(t, schemaVersion, sf.SchemaVersion)
assert.Empty(t, sf.Stacks)
})
t.Run("future schema version returns error", func(t *testing.T) {
dir := t.TempDir()
data, _ := json.Marshal(StackFile{SchemaVersion: 999})
require.NoError(t, os.WriteFile(filepath.Join(dir, stackFileName), data, 0644))
_, err := Load(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "999")
})
t.Run("corrupt JSON returns error", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, stackFileName), []byte("{not json!"), 0644))
_, err := Load(dir)
assert.Error(t, err)
})
}
// --- FindStackByPRNumber: used by checkout ---
func TestFindStackByPRNumber(t *testing.T) {
sf := &StackFile{
Stacks: []Stack{
{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
{Branch: "b1", PullRequest: &PullRequestRef{Number: 10}},
{Branch: "b2", PullRequest: &PullRequestRef{Number: 20}},
},
},
{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
{Branch: "b3", PullRequest: &PullRequestRef{Number: 30}},
},
},
},
}
t.Run("found", func(t *testing.T) {
s, b := sf.FindStackByPRNumber(20)
require.NotNil(t, s)
require.NotNil(t, b)
assert.Equal(t, "b2", b.Branch)
})
t.Run("found in second stack", func(t *testing.T) {
s, b := sf.FindStackByPRNumber(30)
require.NotNil(t, s)
require.NotNil(t, b)
assert.Equal(t, "b3", b.Branch)
})
t.Run("not found", func(t *testing.T) {
s, b := sf.FindStackByPRNumber(999)
assert.Nil(t, s)
assert.Nil(t, b)
})
}
// --- ValidateNoDuplicateBranch: guards against duplicates ---
func TestValidateNoDuplicateBranch(t *testing.T) {
sf := &StackFile{
Stacks: []Stack{
makeStack("main", "b1", "b2"),
},
}
t.Run("branch in stack returns error", func(t *testing.T) {
assert.Error(t, sf.ValidateNoDuplicateBranch("b1"))
})
t.Run("trunk returns error because Contains checks trunk", func(t *testing.T) {
assert.Error(t, sf.ValidateNoDuplicateBranch("main"))
})
t.Run("new branch returns nil", func(t *testing.T) {
assert.NoError(t, sf.ValidateNoDuplicateBranch("new-branch"))
})
}
// --- RemoveStackForBranch: used by unstack ---
func TestRemoveStackForBranch(t *testing.T) {
t.Run("found and removed", func(t *testing.T) {
sf := &StackFile{
Stacks: []Stack{
makeStack("main", "b1"),
makeStack("main", "b2"),
},
}
assert.True(t, sf.RemoveStackForBranch("b1"))
require.Len(t, sf.Stacks, 1)
assert.Equal(t, "b2", sf.Stacks[0].Branches[0].Branch)
})
t.Run("not found", func(t *testing.T) {
sf := &StackFile{
Stacks: []Stack{makeStack("main", "b1")},
}
assert.False(t, sf.RemoveStackForBranch("nonexistent"))
assert.Len(t, sf.Stacks, 1)
})
}
// --- Queued state: transient merge queue support ---
func makeQueuedBranch(name string, prNum int) BranchRef {
return BranchRef{
Branch: name,
PullRequest: &PullRequestRef{Number: prNum},
Queued: true,
}
}
func TestIsQueued(t *testing.T) {
t.Run("queued branch", func(t *testing.T) {
b := makeQueuedBranch("b1", 1)
assert.True(t, b.IsQueued())
assert.False(t, b.IsMerged())
assert.True(t, b.IsSkipped())
})
t.Run("merged branch", func(t *testing.T) {
b := makeMergedBranch("b1", 1)
assert.False(t, b.IsQueued())
assert.True(t, b.IsMerged())
assert.True(t, b.IsSkipped())
})
t.Run("active branch", func(t *testing.T) {
b := BranchRef{Branch: "b1"}
assert.False(t, b.IsQueued())
assert.False(t, b.IsMerged())
assert.False(t, b.IsSkipped())
})
}
func TestQueuedBranches(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
{Branch: "b1"},
makeQueuedBranch("b2", 2),
{Branch: "b3"},
makeQueuedBranch("b4", 4),
},
}
queued := s.QueuedBranches()
assert.Len(t, queued, 2)
assert.Equal(t, "b2", queued[0].Branch)
assert.Equal(t, "b4", queued[1].Branch)
}
func TestActiveBranches_ExcludesQueued(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeQueuedBranch("b1", 1),
{Branch: "b2"},
makeMergedBranch("b3", 3),
{Branch: "b4"},
},
}
active := s.ActiveBranches()
assert.Len(t, active, 2)
assert.Equal(t, "b2", active[0].Branch)
assert.Equal(t, "b4", active[1].Branch)
}
func TestFirstActiveBranchIndex_SkipsQueued(t *testing.T) {
t.Run("queued first, then active", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeQueuedBranch("b1", 1),
{Branch: "b2"},
},
}
assert.Equal(t, 1, s.FirstActiveBranchIndex())
})
t.Run("all queued", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeQueuedBranch("b1", 1),
makeQueuedBranch("b2", 2),
},
}
assert.Equal(t, -1, s.FirstActiveBranchIndex())
})
t.Run("merged then queued then active", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
makeQueuedBranch("b2", 2),
{Branch: "b3"},
},
}
assert.Equal(t, 2, s.FirstActiveBranchIndex())
})
}
func TestActiveBranchIndices_SkipsQueued(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeQueuedBranch("b1", 1),
{Branch: "b2"},
makeMergedBranch("b3", 3),
{Branch: "b4"},
makeQueuedBranch("b5", 5),
},
}
assert.Equal(t, []int{1, 3}, s.ActiveBranchIndices())
}
func TestActiveBaseBranch_SkipsQueued(t *testing.T) {
tests := []struct {
name string
stack Stack
branch string
expected string
}{
{
name: "queued ancestor skipped to trunk",
stack: Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeQueuedBranch("b1", 1),
{Branch: "b2"},
},
},
branch: "b2",
expected: "main",
},
{
name: "queued ancestor skipped to active sibling",
stack: Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
{Branch: "b1"},
makeQueuedBranch("b2", 2),
{Branch: "b3"},
},
},
branch: "b3",
expected: "b1",
},
{
name: "mixed merged and queued ancestors skip to trunk",
stack: Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
makeQueuedBranch("b2", 2),
{Branch: "b3"},
},
},
branch: "b3",
expected: "main",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.stack.ActiveBaseBranch(tt.branch))
})
}
}
func TestQueuedState_NotPersisted(t *testing.T) {
dir := t.TempDir()
original := &StackFile{
Repository: "owner/repo",
Stacks: []Stack{
{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
{
Branch: "b1",
PullRequest: &PullRequestRef{Number: 1},
Queued: true, // set transient state
},
},
},
},
}
require.NoError(t, Save(dir, original))
loaded, err := Load(dir)
require.NoError(t, err)
require.Len(t, loaded.Stacks, 1)
require.Len(t, loaded.Stacks[0].Branches, 1)
// Queued state should NOT be persisted (json:"-")
assert.False(t, loaded.Stacks[0].Branches[0].Queued)
assert.False(t, loaded.Stacks[0].Branches[0].IsQueued())
}
func TestIsFullyMerged_NotAffectedByQueued(t *testing.T) {
t.Run("all queued is not fully merged", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeQueuedBranch("b1", 1),
makeQueuedBranch("b2", 2),
},
}
assert.False(t, s.IsFullyMerged())
})
t.Run("merged and queued is not fully merged", func(t *testing.T) {
s := Stack{
Trunk: BranchRef{Branch: "main"},
Branches: []BranchRef{
makeMergedBranch("b1", 1),
makeQueuedBranch("b2", 2),
},
}
assert.False(t, s.IsFullyMerged())
})
}