mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
dcb5885053
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>
419 lines
12 KiB
Go
419 lines
12 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/github/gh-stack/internal/config"
|
|
"github.com/github/gh-stack/internal/git"
|
|
"github.com/github/gh-stack/internal/github"
|
|
"github.com/github/gh-stack/internal/stack"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestTimeAgo(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
duration time.Duration
|
|
want string
|
|
}{
|
|
{"seconds", 30 * time.Second, "30 seconds ago"},
|
|
{"one second", 1 * time.Second, "1 second ago"},
|
|
{"minutes", 5 * time.Minute, "5 minutes ago"},
|
|
{"one minute", 1 * time.Minute, "1 minute ago"},
|
|
{"hours", 3 * time.Hour, "3 hours ago"},
|
|
{"one hour", 1 * time.Hour, "1 hour ago"},
|
|
{"days", 2 * 24 * time.Hour, "2 days ago"},
|
|
{"one day", 24 * time.Hour, "1 day ago"},
|
|
{"months", 60 * 24 * time.Hour, "2 months ago"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := timeAgo(time.Now().Add(-tt.duration))
|
|
assert.Equal(t, tt.want, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestViewJSON(t *testing.T) {
|
|
git.SetOps(&git.MockOps{
|
|
IsAncestorFn: func(ancestor, descendant string) (bool, error) {
|
|
return true, nil // all branches are linear
|
|
},
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
stack *stack.Stack
|
|
currentBranch string
|
|
wantTrunk string
|
|
wantBranches int
|
|
wantCurrent string
|
|
}{
|
|
{
|
|
name: "basic stack with PRs",
|
|
stack: &stack.Stack{
|
|
Prefix: "feat",
|
|
Trunk: stack.BranchRef{Branch: "main", Head: "aaa"},
|
|
Branches: []stack.BranchRef{
|
|
{
|
|
Branch: "feat/01",
|
|
Head: "bbb",
|
|
Base: "aaa",
|
|
PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"},
|
|
},
|
|
{
|
|
Branch: "feat/02",
|
|
Head: "ccc",
|
|
Base: "bbb",
|
|
PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"},
|
|
},
|
|
},
|
|
},
|
|
currentBranch: "feat/02",
|
|
wantTrunk: "main",
|
|
wantBranches: 2,
|
|
wantCurrent: "feat/02",
|
|
},
|
|
{
|
|
name: "stack with merged branch",
|
|
stack: &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main", Head: "aaa"},
|
|
Branches: []stack.BranchRef{
|
|
{
|
|
Branch: "layer-1",
|
|
Head: "bbb",
|
|
Base: "aaa",
|
|
PullRequest: &stack.PullRequestRef{Number: 10, Merged: true},
|
|
},
|
|
{
|
|
Branch: "layer-2",
|
|
Head: "ccc",
|
|
Base: "bbb",
|
|
},
|
|
},
|
|
},
|
|
currentBranch: "layer-2",
|
|
wantTrunk: "main",
|
|
wantBranches: 2,
|
|
wantCurrent: "layer-2",
|
|
},
|
|
{
|
|
name: "empty stack",
|
|
stack: &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{},
|
|
},
|
|
currentBranch: "main",
|
|
wantTrunk: "main",
|
|
wantBranches: 0,
|
|
wantCurrent: "main",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
defer outR.Close()
|
|
|
|
err := viewJSON(cfg, tt.stack, tt.currentBranch)
|
|
require.NoError(t, err)
|
|
cfg.Out.Close()
|
|
|
|
raw, err := io.ReadAll(outR)
|
|
require.NoError(t, err)
|
|
|
|
var got viewJSONOutput
|
|
err = json.Unmarshal(raw, &got)
|
|
require.NoError(t, err, "output should be valid JSON: %s", string(raw))
|
|
|
|
assert.Equal(t, tt.wantTrunk, got.Trunk)
|
|
assert.Equal(t, tt.wantCurrent, got.CurrentBranch)
|
|
assert.Len(t, got.Branches, tt.wantBranches)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestViewJSON_BranchFields(t *testing.T) {
|
|
git.SetOps(&git.MockOps{
|
|
IsAncestorFn: func(ancestor, descendant string) (bool, error) {
|
|
// feat/02 needs rebase
|
|
if descendant == "feat/02" {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
},
|
|
})
|
|
|
|
s := &stack.Stack{
|
|
Prefix: "feat",
|
|
Trunk: stack.BranchRef{Branch: "main", Head: "aaa111"},
|
|
Branches: []stack.BranchRef{
|
|
{
|
|
Branch: "feat/01",
|
|
Head: "bbb222",
|
|
Base: "aaa111",
|
|
PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42", Merged: true},
|
|
},
|
|
{
|
|
Branch: "feat/02",
|
|
Head: "ccc333",
|
|
Base: "bbb222",
|
|
PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"},
|
|
},
|
|
},
|
|
}
|
|
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
defer outR.Close()
|
|
|
|
err := viewJSON(cfg, s, "feat/02")
|
|
require.NoError(t, err)
|
|
cfg.Out.Close()
|
|
|
|
raw, err := io.ReadAll(outR)
|
|
require.NoError(t, err)
|
|
|
|
var got viewJSONOutput
|
|
require.NoError(t, json.Unmarshal(raw, &got))
|
|
|
|
assert.Equal(t, "feat", got.Prefix)
|
|
|
|
// First branch: merged
|
|
b0 := got.Branches[0]
|
|
assert.Equal(t, "feat/01", b0.Name)
|
|
assert.Equal(t, "bbb222", b0.Head)
|
|
assert.Equal(t, "aaa111", b0.Base)
|
|
assert.False(t, b0.IsCurrent)
|
|
assert.True(t, b0.IsMerged)
|
|
assert.False(t, b0.NeedsRebase, "merged branches should not need rebase")
|
|
require.NotNil(t, b0.PR)
|
|
assert.Equal(t, 42, b0.PR.Number)
|
|
assert.Equal(t, "MERGED", b0.PR.State)
|
|
assert.Equal(t, "https://github.com/o/r/pull/42", b0.PR.URL)
|
|
|
|
// Second branch: current, needs rebase
|
|
b1 := got.Branches[1]
|
|
assert.Equal(t, "feat/02", b1.Name)
|
|
assert.True(t, b1.IsCurrent)
|
|
assert.False(t, b1.IsMerged)
|
|
assert.True(t, b1.NeedsRebase)
|
|
require.NotNil(t, b1.PR)
|
|
assert.Equal(t, 43, b1.PR.Number)
|
|
assert.Equal(t, "OPEN", b1.PR.State)
|
|
}
|
|
|
|
// TestViewShort_ActiveStack verifies that --short output contains all branch
|
|
// names and the trunk for an active stack.
|
|
func TestViewShort_ActiveStack(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
{Branch: "b2"},
|
|
{Branch: "b3"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b2", nil },
|
|
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
|
|
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--short"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Out.Close()
|
|
raw, _ := io.ReadAll(outR)
|
|
output := string(raw)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, output, "b1")
|
|
assert.Contains(t, output, "b2")
|
|
assert.Contains(t, output, "b3")
|
|
assert.Contains(t, output, "main")
|
|
}
|
|
|
|
// TestViewShort_FullyMergedStack verifies that --short output shows merged
|
|
// branches correctly when all branches in the stack are merged.
|
|
func TestViewShort_FullyMergedStack(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "main", nil },
|
|
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
|
|
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--short"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Out.Close()
|
|
raw, _ := io.ReadAll(outR)
|
|
output := string(raw)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, output, "b1")
|
|
assert.Contains(t, output, "b2")
|
|
}
|
|
|
|
// TestViewShort_QueuedStack verifies that --short output shows queued
|
|
// branches with a "queued" separator and the ◎ icon.
|
|
func TestViewShort_QueuedStack(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b3", nil },
|
|
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
|
|
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
|
|
})
|
|
defer restore()
|
|
|
|
// Mock GitHub client to return b1 as queued (MergeQueueEntry set)
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindAnyPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
switch branch {
|
|
case "b1":
|
|
return &github.PullRequest{
|
|
Number: 1,
|
|
ID: "PR_1",
|
|
MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_1"},
|
|
}, nil
|
|
case "b2":
|
|
return &github.PullRequest{Number: 2, ID: "PR_2"}, nil
|
|
case "b3":
|
|
return &github.PullRequest{Number: 3, ID: "PR_3"}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--short"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Out.Close()
|
|
raw, _ := io.ReadAll(outR)
|
|
output := string(raw)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, output, "b1")
|
|
assert.Contains(t, output, "b2")
|
|
assert.Contains(t, output, "b3")
|
|
assert.Contains(t, output, "queued", "should show queued separator")
|
|
assert.Contains(t, output, "◎", "should show queued icon for b1")
|
|
}
|
|
|
|
// TestViewShort_MixedQueuedAndMerged verifies that --short output shows
|
|
// both "queued" and "merged" separators in the correct order.
|
|
func TestViewShort_MixedQueuedAndMerged(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b3", nil },
|
|
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
|
|
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
|
|
})
|
|
defer restore()
|
|
|
|
// b1 is merged (persisted), b2 is queued (from API)
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindAnyPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
switch branch {
|
|
case "b2":
|
|
return &github.PullRequest{
|
|
Number: 2,
|
|
ID: "PR_2",
|
|
MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_2"},
|
|
}, nil
|
|
case "b3":
|
|
return &github.PullRequest{Number: 3, ID: "PR_3"}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--short"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Out.Close()
|
|
raw, _ := io.ReadAll(outR)
|
|
output := string(raw)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, output, "queued", "should show queued separator")
|
|
assert.Contains(t, output, "merged", "should show merged separator")
|
|
|
|
// "merged" section (b1) should appear below "queued" section (b2) in output
|
|
// Since we render top-to-bottom: b3 (active) -> queued separator -> b2 -> merged separator -> b1
|
|
queuedIdx := indexOf(output, "queued")
|
|
mergedIdx := indexOf(output, "merged")
|
|
assert.Less(t, queuedIdx, mergedIdx, "queued separator should appear before merged separator")
|
|
}
|
|
|
|
func indexOf(s, substr string) int {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|