feat(mq): add integration status command (gt-h5n.6)
Implement 'gt mq integration status <epic>' command that displays: - Integration branch name and creation date - Commits ahead of main - Merged MRs (closed, targeting integration branch) - Pending MRs (open, targeting integration branch) Also adds git helpers for BranchCreatedDate and CommitsAhead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,9 @@ var (
|
|||||||
mqIntegrationLandForce bool
|
mqIntegrationLandForce bool
|
||||||
mqIntegrationLandSkipTests bool
|
mqIntegrationLandSkipTests bool
|
||||||
mqIntegrationLandDryRun bool
|
mqIntegrationLandDryRun bool
|
||||||
|
|
||||||
|
// Integration status flags
|
||||||
|
mqIntegrationStatusJSON bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var mqCmd = &cobra.Command{
|
var mqCmd = &cobra.Command{
|
||||||
@@ -169,7 +172,7 @@ branch is landed to main as a single atomic unit.
|
|||||||
Commands:
|
Commands:
|
||||||
create Create an integration branch for an epic
|
create Create an integration branch for an epic
|
||||||
land Merge integration branch to main
|
land Merge integration branch to main
|
||||||
status Show integration branch status (not yet implemented)`,
|
status Show integration branch status`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var mqIntegrationCreateCmd = &cobra.Command{
|
var mqIntegrationCreateCmd = &cobra.Command{
|
||||||
@@ -223,6 +226,23 @@ Examples:
|
|||||||
RunE: runMqIntegrationLand,
|
RunE: runMqIntegrationLand,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var mqIntegrationStatusCmd = &cobra.Command{
|
||||||
|
Use: "status <epic-id>",
|
||||||
|
Short: "Show integration branch status for an epic",
|
||||||
|
Long: `Display the status of an integration branch.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
- Integration branch name and creation date
|
||||||
|
- Number of commits ahead of main
|
||||||
|
- Merged MRs (closed, targeting integration branch)
|
||||||
|
- Pending MRs (open, targeting integration branch)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
gt mq integration status gt-auth-epic`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runMqIntegrationStatus,
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Submit flags
|
// Submit flags
|
||||||
mqSubmitCmd.Flags().StringVar(&mqSubmitBranch, "branch", "", "Source branch (default: current branch)")
|
mqSubmitCmd.Flags().StringVar(&mqSubmitBranch, "branch", "", "Source branch (default: current branch)")
|
||||||
@@ -258,12 +278,16 @@ func init() {
|
|||||||
// Integration branch subcommands
|
// Integration branch subcommands
|
||||||
mqIntegrationCmd.AddCommand(mqIntegrationCreateCmd)
|
mqIntegrationCmd.AddCommand(mqIntegrationCreateCmd)
|
||||||
|
|
||||||
// Integration land flags
|
// Integration land flags
|
||||||
mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandForce, "force", false, "Land even if some MRs still open")
|
mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandForce, "force", false, "Land even if some MRs still open")
|
||||||
mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandSkipTests, "skip-tests", false, "Skip test run")
|
mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandSkipTests, "skip-tests", false, "Skip test run")
|
||||||
mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandDryRun, "dry-run", false, "Preview only, make no changes")
|
mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandDryRun, "dry-run", false, "Preview only, make no changes")
|
||||||
mqIntegrationCmd.AddCommand(mqIntegrationLandCmd)
|
mqIntegrationCmd.AddCommand(mqIntegrationLandCmd)
|
||||||
|
|
||||||
|
// Integration status flags
|
||||||
|
mqIntegrationStatusCmd.Flags().BoolVar(&mqIntegrationStatusJSON, "json", false, "Output as JSON")
|
||||||
|
mqIntegrationCmd.AddCommand(mqIntegrationStatusCmd)
|
||||||
|
|
||||||
mqCmd.AddCommand(mqIntegrationCmd)
|
mqCmd.AddCommand(mqIntegrationCmd)
|
||||||
|
|
||||||
rootCmd.AddCommand(mqCmd)
|
rootCmd.AddCommand(mqCmd)
|
||||||
@@ -1536,3 +1560,177 @@ func detectIntegrationBranch(bd *beads.Beads, g *git.Git, issueID string) (strin
|
|||||||
|
|
||||||
return "", nil // No integration branch found
|
return "", nil // No integration branch found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IntegrationStatusOutput is the JSON output structure for integration status.
|
||||||
|
type IntegrationStatusOutput struct {
|
||||||
|
Epic string `json:"epic"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Created string `json:"created,omitempty"`
|
||||||
|
AheadOfMain int `json:"ahead_of_main"`
|
||||||
|
MergedMRs []IntegrationStatusMRSummary `json:"merged_mrs"`
|
||||||
|
PendingMRs []IntegrationStatusMRSummary `json:"pending_mrs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntegrationStatusMRSummary represents a merge request in the integration status output.
|
||||||
|
type IntegrationStatusMRSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMqIntegrationStatus shows the status of an integration branch for an epic.
|
||||||
|
func runMqIntegrationStatus(cmd *cobra.Command, args []string) error {
|
||||||
|
epicID := args[0]
|
||||||
|
|
||||||
|
// Find workspace
|
||||||
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find current rig
|
||||||
|
_, r, err := findCurrentRig(townRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize beads for the rig
|
||||||
|
bd := beads.New(r.Path)
|
||||||
|
|
||||||
|
// Build integration branch name
|
||||||
|
branchName := "integration/" + epicID
|
||||||
|
|
||||||
|
// Initialize git for the rig
|
||||||
|
g := git.NewGit(r.Path)
|
||||||
|
|
||||||
|
// Fetch from origin to ensure we have latest refs
|
||||||
|
if err := g.Fetch("origin"); err != nil {
|
||||||
|
// Non-fatal, continue with local data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if integration branch exists (locally or remotely)
|
||||||
|
localExists, _ := g.BranchExists(branchName)
|
||||||
|
remoteExists, _ := g.RemoteBranchExists("origin", branchName)
|
||||||
|
|
||||||
|
if !localExists && !remoteExists {
|
||||||
|
return fmt.Errorf("integration branch '%s' does not exist", branchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which ref to use for comparison
|
||||||
|
ref := branchName
|
||||||
|
if !localExists && remoteExists {
|
||||||
|
ref = "origin/" + branchName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get branch creation date
|
||||||
|
createdDate, err := g.BranchCreatedDate(ref)
|
||||||
|
if err != nil {
|
||||||
|
createdDate = "" // Non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get commits ahead of main
|
||||||
|
aheadCount, err := g.CommitsAhead("main", ref)
|
||||||
|
if err != nil {
|
||||||
|
aheadCount = 0 // Non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query for MRs targeting this integration branch
|
||||||
|
targetBranch := "integration/" + epicID
|
||||||
|
|
||||||
|
// Get all merge-request issues
|
||||||
|
allMRs, err := bd.List(beads.ListOptions{
|
||||||
|
Type: "merge-request",
|
||||||
|
Status: "", // all statuses
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("querying merge requests: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by target branch and separate into merged/pending
|
||||||
|
var mergedMRs, pendingMRs []*beads.Issue
|
||||||
|
for _, mr := range allMRs {
|
||||||
|
fields := beads.ParseMRFields(mr)
|
||||||
|
if fields == nil || fields.Target != targetBranch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if mr.Status == "closed" {
|
||||||
|
mergedMRs = append(mergedMRs, mr)
|
||||||
|
} else {
|
||||||
|
pendingMRs = append(pendingMRs, mr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build output structure
|
||||||
|
output := IntegrationStatusOutput{
|
||||||
|
Epic: epicID,
|
||||||
|
Branch: branchName,
|
||||||
|
Created: createdDate,
|
||||||
|
AheadOfMain: aheadCount,
|
||||||
|
MergedMRs: make([]IntegrationStatusMRSummary, 0, len(mergedMRs)),
|
||||||
|
PendingMRs: make([]IntegrationStatusMRSummary, 0, len(pendingMRs)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mr := range mergedMRs {
|
||||||
|
// Extract the title without "Merge: " prefix for cleaner display
|
||||||
|
title := strings.TrimPrefix(mr.Title, "Merge: ")
|
||||||
|
output.MergedMRs = append(output.MergedMRs, IntegrationStatusMRSummary{
|
||||||
|
ID: mr.ID,
|
||||||
|
Title: title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mr := range pendingMRs {
|
||||||
|
title := strings.TrimPrefix(mr.Title, "Merge: ")
|
||||||
|
output.PendingMRs = append(output.PendingMRs, IntegrationStatusMRSummary{
|
||||||
|
ID: mr.ID,
|
||||||
|
Title: title,
|
||||||
|
Status: mr.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON output
|
||||||
|
if mqIntegrationStatusJSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
return printIntegrationStatus(&output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printIntegrationStatus prints the integration status in human-readable format.
|
||||||
|
func printIntegrationStatus(output *IntegrationStatusOutput) error {
|
||||||
|
fmt.Printf("Integration: %s\n", style.Bold.Render(output.Branch))
|
||||||
|
if output.Created != "" {
|
||||||
|
fmt.Printf("Created: %s\n", output.Created)
|
||||||
|
}
|
||||||
|
fmt.Printf("Ahead of main: %d commits\n", output.AheadOfMain)
|
||||||
|
|
||||||
|
// Merged MRs
|
||||||
|
fmt.Printf("\nMerged MRs (%d):\n", len(output.MergedMRs))
|
||||||
|
if len(output.MergedMRs) == 0 {
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render("(none)"))
|
||||||
|
} else {
|
||||||
|
for _, mr := range output.MergedMRs {
|
||||||
|
fmt.Printf(" %-12s %s\n", mr.ID, mr.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending MRs
|
||||||
|
fmt.Printf("\nPending MRs (%d):\n", len(output.PendingMRs))
|
||||||
|
if len(output.PendingMRs) == 0 {
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render("(none)"))
|
||||||
|
} else {
|
||||||
|
for _, mr := range output.PendingMRs {
|
||||||
|
statusInfo := ""
|
||||||
|
if mr.Status != "" && mr.Status != "open" {
|
||||||
|
statusInfo = fmt.Sprintf(" (%s)", mr.Status)
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-12s %s%s\n", mr.ID, mr.Title, style.Dim.Render(statusInfo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -470,3 +470,58 @@ func (g *Git) WorktreeList() ([]Worktree, error) {
|
|||||||
|
|
||||||
return worktrees, nil
|
return worktrees, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BranchCreatedDate returns the date when a branch was created.
|
||||||
|
// This uses the committer date of the first commit on the branch.
|
||||||
|
// Returns date in YYYY-MM-DD format.
|
||||||
|
func (g *Git) BranchCreatedDate(branch string) (string, error) {
|
||||||
|
// Get the date of the first commit on the branch that's not on main
|
||||||
|
// Use merge-base to find where the branch diverged from main
|
||||||
|
mergeBase, err := g.run("merge-base", "main", branch)
|
||||||
|
if err != nil {
|
||||||
|
// If merge-base fails, fall back to the branch tip's date
|
||||||
|
out, err := g.run("log", "-1", "--format=%cs", branch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first commit after the merge base on this branch
|
||||||
|
out, err := g.run("log", "--format=%cs", "--reverse", mergeBase+".."+branch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first line (first commit's date)
|
||||||
|
lines := strings.Split(out, "\n")
|
||||||
|
if len(lines) > 0 && lines[0] != "" {
|
||||||
|
return lines[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no commits after merge-base, the branch points to merge-base
|
||||||
|
// Return the merge-base commit date
|
||||||
|
out, err = g.run("log", "-1", "--format=%cs", mergeBase)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitsAhead returns the number of commits that branch has ahead of base.
|
||||||
|
// For example, CommitsAhead("main", "feature") returns how many commits
|
||||||
|
// are on feature that are not on main.
|
||||||
|
func (g *Git) CommitsAhead(base, branch string) (int, error) {
|
||||||
|
out, err := g.run("rev-list", "--count", base+".."+branch)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
_, err = fmt.Sscanf(out, "%d", &count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("parsing commit count: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user