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>
413 lines
10 KiB
Go
413 lines
10 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/github/gh-stack/internal/config"
|
|
"github.com/github/gh-stack/internal/git"
|
|
ghapi "github.com/github/gh-stack/internal/github"
|
|
"github.com/github/gh-stack/internal/stack"
|
|
"github.com/github/gh-stack/internal/tui/stackview"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type viewOptions struct {
|
|
short bool
|
|
asJSON bool
|
|
}
|
|
|
|
func ViewCmd(cfg *config.Config) *cobra.Command {
|
|
opts := &viewOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "view",
|
|
Short: "View the current stack",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runView(cfg, opts)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVarP(&opts.short, "short", "s", false, "Show compact output")
|
|
cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output stack data as JSON")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func runView(cfg *config.Config, opts *viewOptions) error {
|
|
result, err := loadStack(cfg, "")
|
|
if err != nil {
|
|
return ErrNotInStack
|
|
}
|
|
gitDir := result.GitDir
|
|
sf := result.StackFile
|
|
s := result.Stack
|
|
currentBranch := result.CurrentBranch
|
|
|
|
// Sync PR state and save (best-effort).
|
|
syncStackPRs(cfg, s)
|
|
stack.SaveNonBlocking(gitDir, sf)
|
|
|
|
if opts.asJSON {
|
|
return viewJSON(cfg, s, currentBranch)
|
|
}
|
|
|
|
if opts.short {
|
|
return viewShort(cfg, s, currentBranch)
|
|
}
|
|
|
|
return viewFull(cfg, s, currentBranch)
|
|
}
|
|
|
|
func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error {
|
|
var repoHost, repoOwner, repoName string
|
|
if repo, err := cfg.Repo(); err == nil {
|
|
repoHost = repo.Host
|
|
repoOwner = repo.Owner
|
|
repoName = repo.Name
|
|
}
|
|
|
|
for i := len(s.Branches) - 1; i >= 0; i-- {
|
|
b := s.Branches[i]
|
|
merged := b.IsMerged()
|
|
queued := b.IsQueued()
|
|
|
|
// Insert separator when transitioning from active to queued section
|
|
if queued && !merged && (i == len(s.Branches)-1 || (!s.Branches[i+1].IsQueued() && !s.Branches[i+1].IsMerged())) {
|
|
cfg.Outf("├─── %s ────\n", cfg.ColorWarning("queued"))
|
|
}
|
|
|
|
// Insert separator when transitioning from active/queued to merged section
|
|
if merged && (i == len(s.Branches)-1 || !s.Branches[i+1].IsMerged()) {
|
|
cfg.Outf("├─── %s ────\n", cfg.ColorMagenta("merged"))
|
|
}
|
|
|
|
indicator := branchStatusIndicator(cfg, s, b)
|
|
prSuffix := shortPRSuffix(cfg, b, repoHost, repoOwner, repoName)
|
|
if b.Branch == currentBranch {
|
|
cfg.Outf("» %s%s%s %s\n", cfg.ColorBold(b.Branch), indicator, prSuffix, cfg.ColorCyan("(current)"))
|
|
} else if merged {
|
|
cfg.Outf("│ %s%s%s\n", cfg.ColorGray(b.Branch), indicator, prSuffix)
|
|
} else if queued {
|
|
cfg.Outf("│ %s%s%s\n", cfg.ColorWarning(b.Branch), indicator, prSuffix)
|
|
} else {
|
|
cfg.Outf("├ %s%s%s\n", b.Branch, indicator, prSuffix)
|
|
}
|
|
}
|
|
cfg.Outf("└ %s\n", s.Trunk.Branch)
|
|
return nil
|
|
}
|
|
|
|
// branchStatusIndicator returns a colored status icon for a branch:
|
|
// - ✓ (purple) if the PR has been merged
|
|
// - ◎ (yellow) if the PR is queued in a merge queue
|
|
// - ⚠ (yellow) if the branch needs rebasing (non-linear history)
|
|
// - ○ (green) if there is an open PR
|
|
func branchStatusIndicator(cfg *config.Config, s *stack.Stack, b stack.BranchRef) string {
|
|
if b.IsMerged() {
|
|
return " " + cfg.ColorMagenta("✓")
|
|
}
|
|
|
|
if b.IsQueued() {
|
|
return " " + cfg.ColorWarning("◎")
|
|
}
|
|
|
|
baseBranch := s.ActiveBaseBranch(b.Branch)
|
|
if needsRebase, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !needsRebase {
|
|
return " " + cfg.ColorWarning("⚠")
|
|
}
|
|
|
|
if b.PullRequest != nil && b.PullRequest.Number != 0 {
|
|
return " " + cfg.ColorSuccess("○")
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// JSON output types for gh stack view --json.
|
|
type viewJSONOutput struct {
|
|
Trunk string `json:"trunk"`
|
|
Prefix string `json:"prefix,omitempty"`
|
|
CurrentBranch string `json:"currentBranch"`
|
|
Branches []viewJSONBranch `json:"branches"`
|
|
}
|
|
|
|
type viewJSONBranch struct {
|
|
Name string `json:"name"`
|
|
Head string `json:"head,omitempty"`
|
|
Base string `json:"base,omitempty"`
|
|
IsCurrent bool `json:"isCurrent"`
|
|
IsMerged bool `json:"isMerged"`
|
|
IsQueued bool `json:"isQueued"`
|
|
NeedsRebase bool `json:"needsRebase"`
|
|
PR *viewJSONPR `json:"pr,omitempty"`
|
|
}
|
|
|
|
type viewJSONPR struct {
|
|
Number int `json:"number"`
|
|
URL string `json:"url,omitempty"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
func viewJSON(cfg *config.Config, s *stack.Stack, currentBranch string) error {
|
|
out := viewJSONOutput{
|
|
Trunk: s.Trunk.Branch,
|
|
Prefix: s.Prefix,
|
|
CurrentBranch: currentBranch,
|
|
Branches: make([]viewJSONBranch, 0, len(s.Branches)),
|
|
}
|
|
|
|
for _, b := range s.Branches {
|
|
jb := viewJSONBranch{
|
|
Name: b.Branch,
|
|
Head: b.Head,
|
|
Base: b.Base,
|
|
IsCurrent: b.Branch == currentBranch,
|
|
IsMerged: b.IsMerged(),
|
|
IsQueued: b.IsQueued(),
|
|
}
|
|
|
|
// Check if the branch needs rebasing (base not ancestor of branch).
|
|
if !jb.IsMerged {
|
|
baseBranch := s.ActiveBaseBranch(b.Branch)
|
|
if isAnc, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !isAnc {
|
|
jb.NeedsRebase = true
|
|
}
|
|
}
|
|
|
|
if b.PullRequest != nil && b.PullRequest.Number != 0 {
|
|
state := "OPEN"
|
|
if b.PullRequest.Merged {
|
|
state = "MERGED"
|
|
} else if b.IsQueued() {
|
|
state = "QUEUED"
|
|
}
|
|
jb.PR = &viewJSONPR{
|
|
Number: b.PullRequest.Number,
|
|
URL: b.PullRequest.URL,
|
|
State: state,
|
|
}
|
|
}
|
|
|
|
out.Branches = append(out.Branches, jb)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(out, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshalling JSON: %w", err)
|
|
}
|
|
_, err = fmt.Fprintf(cfg.Out, "%s\n", data)
|
|
return err
|
|
}
|
|
|
|
func shortPRSuffix(cfg *config.Config, b stack.BranchRef, host, owner, repo string) string {
|
|
if b.PullRequest == nil || b.PullRequest.Number == 0 {
|
|
return ""
|
|
}
|
|
url := b.PullRequest.URL
|
|
if url == "" && owner != "" && repo != "" {
|
|
url = ghapi.PRURL(host, owner, repo, b.PullRequest.Number)
|
|
}
|
|
prNum := cfg.PRLink(b.PullRequest.Number, url)
|
|
colorFn := cfg.ColorSuccess // green for open
|
|
if b.PullRequest.Merged {
|
|
colorFn = cfg.ColorMagenta // purple for merged
|
|
}
|
|
return fmt.Sprintf(" %s", colorFn(prNum))
|
|
}
|
|
|
|
func viewFull(cfg *config.Config, s *stack.Stack, currentBranch string) error {
|
|
if !cfg.IsInteractive() {
|
|
return viewFullStatic(cfg, s, currentBranch)
|
|
}
|
|
|
|
return viewFullTUI(cfg, s, currentBranch)
|
|
}
|
|
|
|
func viewFullTUI(cfg *config.Config, s *stack.Stack, currentBranch string) error {
|
|
// Load enriched data for all branches
|
|
nodes := stackview.LoadBranchNodes(cfg, s, currentBranch)
|
|
|
|
// Reverse nodes so index 0 = top of stack (matches visual order)
|
|
reversed := make([]stackview.BranchNode, len(nodes))
|
|
for i, n := range nodes {
|
|
reversed[len(nodes)-1-i] = n
|
|
}
|
|
|
|
model := stackview.New(reversed, s.Trunk, Version)
|
|
|
|
p := tea.NewProgram(
|
|
model,
|
|
tea.WithAltScreen(),
|
|
tea.WithMouseAllMotion(),
|
|
)
|
|
|
|
finalModel, err := p.Run()
|
|
if err != nil {
|
|
return fmt.Errorf("running TUI: %w", err)
|
|
}
|
|
|
|
// Checkout branch if user requested it
|
|
if m, ok := finalModel.(stackview.Model); ok {
|
|
if branch := m.CheckoutBranch(); branch != "" {
|
|
if err := git.CheckoutBranch(branch); err != nil {
|
|
cfg.Errorf("failed to checkout %s: %v", branch, err)
|
|
} else {
|
|
cfg.Successf("Switched to %s", branch)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func viewFullStatic(cfg *config.Config, s *stack.Stack, currentBranch string) error {
|
|
client, clientErr := cfg.GitHubClient()
|
|
|
|
var repoHost, repoOwner, repoName string
|
|
repo, repoErr := cfg.Repo()
|
|
if repoErr == nil {
|
|
repoHost = repo.Host
|
|
repoOwner = repo.Owner
|
|
repoName = repo.Name
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
for i := len(s.Branches) - 1; i >= 0; i-- {
|
|
b := s.Branches[i]
|
|
|
|
// Insert separator when transitioning from active to merged section
|
|
if b.IsMerged() && (i == len(s.Branches)-1 || !s.Branches[i+1].IsMerged()) {
|
|
fmt.Fprintf(&buf, "╌╌╌ %s ╌╌╌\n", cfg.ColorGray("merged"))
|
|
}
|
|
|
|
isCurrent := b.Branch == currentBranch
|
|
|
|
bullet := "○"
|
|
if isCurrent {
|
|
bullet = "●"
|
|
}
|
|
|
|
indicator := branchStatusIndicator(cfg, s, b)
|
|
|
|
prInfo := ""
|
|
if b.PullRequest != nil {
|
|
if url := b.PullRequest.URL; url != "" {
|
|
prInfo = " " + url
|
|
}
|
|
} else if clientErr == nil && repoErr == nil {
|
|
pr, err := client.FindPRForBranch(b.Branch)
|
|
if err == nil && pr != nil {
|
|
prInfo = " " + ghapi.PRURL(repoHost, repoOwner, repoName, pr.Number)
|
|
}
|
|
}
|
|
|
|
branchName := cfg.ColorMagenta(b.Branch)
|
|
if isCurrent {
|
|
branchName = cfg.ColorCyan(b.Branch + " (current)")
|
|
}
|
|
|
|
fmt.Fprintf(&buf, "%s %s %s%s\n", bullet, branchName, indicator, prInfo)
|
|
|
|
commits, err := git.Log(b.Branch, 1)
|
|
if err == nil && len(commits) > 0 {
|
|
c := commits[0]
|
|
short := c.SHA
|
|
if len(short) > 7 {
|
|
short = short[:7]
|
|
}
|
|
fmt.Fprintf(&buf, "│ %s %s\n", short, cfg.ColorGray("· "+timeAgo(c.Time)))
|
|
fmt.Fprintf(&buf, "│ %s\n", cfg.ColorGray(c.Subject))
|
|
}
|
|
|
|
fmt.Fprintf(&buf, "│\n")
|
|
}
|
|
|
|
fmt.Fprintf(&buf, "└ %s\n", s.Trunk.Branch)
|
|
|
|
return runPager(cfg, buf.String())
|
|
}
|
|
|
|
func runPager(cfg *config.Config, content string) error {
|
|
if !cfg.IsInteractive() {
|
|
_, err := fmt.Fprint(cfg.Out, content)
|
|
return err
|
|
}
|
|
|
|
pagerCmd := os.Getenv("GIT_PAGER")
|
|
if pagerCmd == "" {
|
|
pagerCmd = os.Getenv("PAGER")
|
|
}
|
|
if pagerCmd == "" {
|
|
pagerCmd = "less"
|
|
}
|
|
|
|
args := strings.Fields(pagerCmd)
|
|
if len(args) == 0 {
|
|
_, err := fmt.Fprint(cfg.Out, content)
|
|
return err
|
|
}
|
|
if args[0] == "less" {
|
|
hasR := false
|
|
for _, a := range args[1:] {
|
|
if strings.Contains(a, "R") {
|
|
hasR = true
|
|
break
|
|
}
|
|
}
|
|
if !hasR {
|
|
args = append(args, "-R")
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Stdout = cfg.Out
|
|
cmd.Stderr = cfg.Err
|
|
cmd.Stdin = strings.NewReader(content)
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
func timeAgo(t time.Time) string {
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
secs := int(d.Seconds())
|
|
if secs == 1 {
|
|
return "1 second ago"
|
|
}
|
|
return fmt.Sprintf("%d seconds ago", secs)
|
|
case d < time.Hour:
|
|
mins := int(d.Minutes())
|
|
if mins == 1 {
|
|
return "1 minute ago"
|
|
}
|
|
return fmt.Sprintf("%d minutes ago", mins)
|
|
case d < 24*time.Hour:
|
|
hours := int(d.Hours())
|
|
if hours == 1 {
|
|
return "1 hour ago"
|
|
}
|
|
return fmt.Sprintf("%d hours ago", hours)
|
|
case d < 30*24*time.Hour:
|
|
days := int(d.Hours() / 24)
|
|
if days == 1 {
|
|
return "1 day ago"
|
|
}
|
|
return fmt.Sprintf("%d days ago", days)
|
|
default:
|
|
months := int(d.Hours() / 24 / 30)
|
|
if months <= 1 {
|
|
return "1 month ago"
|
|
}
|
|
return fmt.Sprintf("%d months ago", months)
|
|
}
|
|
}
|