From 61f94d1e0438d48ffa8986a03d5f1ab8185abbc7 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 20 Dec 2025 23:25:11 -0800 Subject: [PATCH] refactor: split mq.go (1738 lines) into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mq.go (400): commands, flags, init, shared helpers - mq_integration.go (606): integration branch create/land/status - mq_status.go (357): status display and formatting - mq_submit.go (219): submit command and branch parsing - mq_list.go (206): list command and filtering Also adds unit tests for helper functions: - formatStatus, getStatusIcon, formatTimeAgo - filterMRsByTarget with edge cases - Test utilities for mocking beads 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mq.go | 1340 +----------------------------- internal/cmd/mq_integration.go | 609 ++++++++++++++ internal/cmd/mq_list.go | 206 +++++ internal/cmd/mq_status.go | 357 ++++++++ internal/cmd/mq_submit.go | 219 +++++ internal/cmd/mq_test.go | 222 +++++ internal/cmd/mq_testutil_test.go | 96 +++ 7 files changed, 1710 insertions(+), 1339 deletions(-) create mode 100644 internal/cmd/mq_integration.go create mode 100644 internal/cmd/mq_list.go create mode 100644 internal/cmd/mq_status.go create mode 100644 internal/cmd/mq_submit.go create mode 100644 internal/cmd/mq_testutil_test.go diff --git a/internal/cmd/mq.go b/internal/cmd/mq.go index 4ec14bce..70279069 100644 --- a/internal/cmd/mq.go +++ b/internal/cmd/mq.go @@ -1,23 +1,17 @@ package cmd import ( - "encoding/json" "fmt" "os" - "os/exec" "path/filepath" - "regexp" "strings" - "time" "github.com/spf13/cobra" - "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/refinery" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/style" - "github.com/steveyegge/gastown/internal/workspace" ) // MQ command flags @@ -278,7 +272,7 @@ func init() { // Integration branch subcommands 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(&mqIntegrationLandSkipTests, "skip-tests", false, "Skip test run") mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandDryRun, "dry-run", false, "Preview only, make no changes") @@ -293,40 +287,6 @@ func init() { rootCmd.AddCommand(mqCmd) } -// branchInfo holds parsed branch information. -type branchInfo struct { - Branch string // Full branch name - Issue string // Issue ID extracted from branch - Worker string // Worker name (polecat name) -} - -// parseBranchName extracts issue ID and worker from a branch name. -// Supports formats: -// - polecat// → issue=, worker= -// - → issue=, worker="" -func parseBranchName(branch string) branchInfo { - info := branchInfo{Branch: branch} - - // Try polecat// format - if strings.HasPrefix(branch, "polecat/") { - parts := strings.SplitN(branch, "/", 3) - if len(parts) == 3 { - info.Worker = parts[1] - info.Issue = parts[2] - return info - } - } - - // Try to find an issue ID pattern in the branch name - // Common patterns: prefix-xxx, prefix-xxx.n (subtask) - issuePattern := regexp.MustCompile(`([a-z]+-[a-z0-9]+(?:\.[0-9]+)?)`) - if matches := issuePattern.FindStringSubmatch(branch); len(matches) > 1 { - info.Issue = matches[1] - } - - return info -} - // findCurrentRig determines the current rig from the working directory. // Returns the rig name and rig object, or an error if not in a rig. func findCurrentRig(townRoot string) (string, *rig.Rig, error) { @@ -366,127 +326,6 @@ func findCurrentRig(townRoot string) (string, *rig.Rig, error) { return rigName, r, nil } -func runMqSubmit(cmd *cobra.Command, args []string) error { - // Find workspace - townRoot, err := workspace.FindFromCwdOrError() - if err != nil { - return fmt.Errorf("not in a Gas Town workspace: %w", err) - } - - // Find current rig - rigName, _, err := findCurrentRig(townRoot) - if err != nil { - return err - } - - // Initialize git for the current directory - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("getting current directory: %w", err) - } - g := git.NewGit(cwd) - - // Get current branch - branch := mqSubmitBranch - if branch == "" { - branch, err = g.CurrentBranch() - if err != nil { - return fmt.Errorf("getting current branch: %w", err) - } - } - - if branch == "main" || branch == "master" { - return fmt.Errorf("cannot submit main/master branch to merge queue") - } - - // Parse branch info - info := parseBranchName(branch) - - // Override with explicit flags - issueID := mqSubmitIssue - if issueID == "" { - issueID = info.Issue - } - worker := info.Worker - - if issueID == "" { - return fmt.Errorf("cannot determine source issue from branch '%s'; use --issue to specify", branch) - } - - // Initialize beads for looking up source issue - bd := beads.New(cwd) - - // Determine target branch - target := "main" - if mqSubmitEpic != "" { - // Explicit --epic flag takes precedence - target = "integration/" + mqSubmitEpic - } else { - // Auto-detect: check if source issue has a parent epic with an integration branch - autoTarget, err := detectIntegrationBranch(bd, g, issueID) - if err != nil { - // Non-fatal: log and continue with main as target - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(note: %v)", err))) - } else if autoTarget != "" { - target = autoTarget - } - } - - // Get source issue for priority inheritance - var priority int - if mqSubmitPriority >= 0 { - priority = mqSubmitPriority - } else { - // Try to inherit from source issue - sourceIssue, err := bd.Show(issueID) - if err != nil { - // Issue not found, use default priority - priority = 2 - } else { - priority = sourceIssue.Priority - } - } - - // Build title - title := fmt.Sprintf("Merge: %s", issueID) - - // Build description with MR fields - mrFields := &beads.MRFields{ - Branch: branch, - Target: target, - SourceIssue: issueID, - Worker: worker, - Rig: rigName, - } - description := beads.FormatMRFields(mrFields) - - // Create the merge-request issue - createOpts := beads.CreateOptions{ - Title: title, - Type: "merge-request", - Priority: priority, - Description: description, - } - - issue, err := bd.Create(createOpts) - if err != nil { - return fmt.Errorf("creating merge request: %w", err) - } - - // Success output - fmt.Printf("%s Created merge request\n", style.Bold.Render("✓")) - fmt.Printf(" MR ID: %s\n", style.Bold.Render(issue.ID)) - fmt.Printf(" Source: %s\n", branch) - fmt.Printf(" Target: %s\n", target) - fmt.Printf(" Issue: %s\n", issueID) - if worker != "" { - fmt.Printf(" Worker: %s\n", worker) - } - fmt.Printf(" Priority: P%d\n", priority) - - return nil -} - func runMQRetry(cmd *cobra.Command, args []string) error { rigName := args[0] mrID := args[1] @@ -531,199 +370,6 @@ func runMQRetry(cmd *cobra.Command, args []string) error { return nil } -func runMQList(cmd *cobra.Command, args []string) error { - rigName := args[0] - - _, r, err := getRefineryManager(rigName) - if err != nil { - return err - } - - // Create beads wrapper for the rig - b := beads.New(r.Path) - - // Build list options - query for merge-request type - // Priority -1 means no priority filter (otherwise 0 would filter to P0 only) - opts := beads.ListOptions{ - Type: "merge-request", - Priority: -1, - } - - // Apply status filter if specified - if mqListStatus != "" { - opts.Status = mqListStatus - } else if !mqListReady { - // Default to open if not showing ready - opts.Status = "open" - } - - var issues []*beads.Issue - - if mqListReady { - // Use ready query which filters by no blockers - allReady, err := b.Ready() - if err != nil { - return fmt.Errorf("querying ready MRs: %w", err) - } - // Filter to only merge-request type - for _, issue := range allReady { - if issue.Type == "merge-request" { - issues = append(issues, issue) - } - } - } else { - issues, err = b.List(opts) - if err != nil { - return fmt.Errorf("querying merge queue: %w", err) - } - } - - // Apply additional filters - var filtered []*beads.Issue - for _, issue := range issues { - // Parse MR fields - fields := beads.ParseMRFields(issue) - - // Filter by worker - if mqListWorker != "" { - worker := "" - if fields != nil { - worker = fields.Worker - } - if !strings.EqualFold(worker, mqListWorker) { - continue - } - } - - // Filter by epic (target branch) - if mqListEpic != "" { - target := "" - if fields != nil { - target = fields.Target - } - expectedTarget := "integration/" + mqListEpic - if target != expectedTarget { - continue - } - } - - filtered = append(filtered, issue) - } - - // JSON output - if mqListJSON { - return outputJSON(filtered) - } - - // Human-readable output - fmt.Printf("%s Merge queue for '%s':\n\n", style.Bold.Render("📋"), rigName) - - if len(filtered) == 0 { - fmt.Printf(" %s\n", style.Dim.Render("(empty)")) - return nil - } - - // Print header - fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n", - "ID", "STATUS", "PRIORITY", "BRANCH", "WORKER", "AGE") - fmt.Printf(" %s\n", strings.Repeat("-", 90)) - - // Print each MR - for _, issue := range filtered { - fields := beads.ParseMRFields(issue) - - // Determine display status - displayStatus := issue.Status - if issue.Status == "open" { - if len(issue.BlockedBy) > 0 || issue.BlockedByCount > 0 { - displayStatus = "blocked" - } else { - displayStatus = "ready" - } - } - - // Format status with styling - styledStatus := displayStatus - switch displayStatus { - case "ready": - styledStatus = style.Bold.Render("ready") - case "in_progress": - styledStatus = style.Bold.Render("in_progress") - case "blocked": - styledStatus = style.Dim.Render("blocked") - case "closed": - styledStatus = style.Dim.Render("closed") - } - - // Get MR fields - branch := "" - worker := "" - if fields != nil { - branch = fields.Branch - worker = fields.Worker - } - - // Truncate branch if too long - if len(branch) > 30 { - branch = branch[:27] + "..." - } - - // Format priority - priority := fmt.Sprintf("P%d", issue.Priority) - - // Calculate age - age := formatMRAge(issue.CreatedAt) - - // Truncate ID if needed - displayID := issue.ID - if len(displayID) > 12 { - displayID = displayID[:12] - } - - fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n", - displayID, styledStatus, priority, branch, worker, style.Dim.Render(age)) - - // Show blocking info if blocked - if displayStatus == "blocked" && len(issue.BlockedBy) > 0 { - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf(" (waiting on %s)", issue.BlockedBy[0]))) - } - } - - return nil -} - -// formatMRAge formats the age of an MR from its created_at timestamp. -func formatMRAge(createdAt string) string { - t, err := time.Parse(time.RFC3339, createdAt) - if err != nil { - // Try other formats - t, err = time.Parse("2006-01-02T15:04:05Z", createdAt) - if err != nil { - return "?" - } - } - - d := time.Since(t) - - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) - } - if d < time.Hour { - return fmt.Sprintf("%dm", int(d.Minutes())) - } - if d < 24*time.Hour { - return fmt.Sprintf("%dh", int(d.Hours())) - } - return fmt.Sprintf("%dd", int(d.Hours()/24)) -} - -// outputJSON outputs data as JSON. -func outputJSON(data interface{}) error { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(data) -} - func runMQReject(cmd *cobra.Command, args []string) error { rigName := args[0] mrIDOrBranch := args[1] @@ -752,987 +398,3 @@ func runMQReject(cmd *cobra.Command, args []string) error { return nil } - -// MRStatusOutput is the JSON output structure for gt mq status. -type MRStatusOutput struct { - // Core issue fields - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Priority int `json:"priority"` - Type string `json:"type"` - Assignee string `json:"assignee,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - ClosedAt string `json:"closed_at,omitempty"` - - // MR-specific fields - Branch string `json:"branch,omitempty"` - Target string `json:"target,omitempty"` - SourceIssue string `json:"source_issue,omitempty"` - Worker string `json:"worker,omitempty"` - Rig string `json:"rig,omitempty"` - MergeCommit string `json:"merge_commit,omitempty"` - CloseReason string `json:"close_reason,omitempty"` - - // Dependencies - DependsOn []DependencyInfo `json:"depends_on,omitempty"` - Blocks []DependencyInfo `json:"blocks,omitempty"` -} - -// DependencyInfo represents a dependency or blocker. -type DependencyInfo struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Priority int `json:"priority"` - Type string `json:"type"` -} - -func runMqStatus(cmd *cobra.Command, args []string) error { - mrID := args[0] - - // Use current working directory for beads operations - // (beads repos are per-rig, not per-workspace) - workDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("getting current directory: %w", err) - } - - // Initialize beads client - bd := beads.New(workDir) - - // Fetch the issue - issue, err := bd.Show(mrID) - if err != nil { - if err == beads.ErrNotFound { - return fmt.Errorf("merge request '%s' not found", mrID) - } - return fmt.Errorf("fetching merge request: %w", err) - } - - // Parse MR-specific fields from description - mrFields := beads.ParseMRFields(issue) - - // Build output structure - output := MRStatusOutput{ - ID: issue.ID, - Title: issue.Title, - Status: issue.Status, - Priority: issue.Priority, - Type: issue.Type, - Assignee: issue.Assignee, - CreatedAt: issue.CreatedAt, - UpdatedAt: issue.UpdatedAt, - ClosedAt: issue.ClosedAt, - } - - // Add MR fields if present - if mrFields != nil { - output.Branch = mrFields.Branch - output.Target = mrFields.Target - output.SourceIssue = mrFields.SourceIssue - output.Worker = mrFields.Worker - output.Rig = mrFields.Rig - output.MergeCommit = mrFields.MergeCommit - output.CloseReason = mrFields.CloseReason - } - - // Add dependency info from the issue's Dependencies field - for _, dep := range issue.Dependencies { - output.DependsOn = append(output.DependsOn, DependencyInfo{ - ID: dep.ID, - Title: dep.Title, - Status: dep.Status, - Priority: dep.Priority, - Type: dep.Type, - }) - } - - // Add blocker info from the issue's Dependents field - for _, dep := range issue.Dependents { - output.Blocks = append(output.Blocks, DependencyInfo{ - ID: dep.ID, - Title: dep.Title, - Status: dep.Status, - Priority: dep.Priority, - Type: dep.Type, - }) - } - - // JSON output - if mqStatusJSON { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(output) - } - - // Human-readable output - return printMqStatus(issue, mrFields) -} - -// printMqStatus prints detailed MR status in human-readable format. -func printMqStatus(issue *beads.Issue, mrFields *beads.MRFields) error { - // Header - fmt.Printf("%s %s\n", style.Bold.Render("📋 Merge Request:"), issue.ID) - fmt.Printf(" %s\n\n", issue.Title) - - // Status section - fmt.Printf("%s\n", style.Bold.Render("Status")) - statusDisplay := formatStatus(issue.Status) - fmt.Printf(" State: %s\n", statusDisplay) - fmt.Printf(" Priority: P%d\n", issue.Priority) - if issue.Type != "" { - fmt.Printf(" Type: %s\n", issue.Type) - } - if issue.Assignee != "" { - fmt.Printf(" Assignee: %s\n", issue.Assignee) - } - - // Timestamps - fmt.Printf("\n%s\n", style.Bold.Render("Timeline")) - if issue.CreatedAt != "" { - fmt.Printf(" Created: %s %s\n", issue.CreatedAt, formatTimeAgo(issue.CreatedAt)) - } - if issue.UpdatedAt != "" && issue.UpdatedAt != issue.CreatedAt { - fmt.Printf(" Updated: %s %s\n", issue.UpdatedAt, formatTimeAgo(issue.UpdatedAt)) - } - if issue.ClosedAt != "" { - fmt.Printf(" Closed: %s %s\n", issue.ClosedAt, formatTimeAgo(issue.ClosedAt)) - } - - // MR-specific fields - if mrFields != nil { - fmt.Printf("\n%s\n", style.Bold.Render("Merge Details")) - if mrFields.Branch != "" { - fmt.Printf(" Branch: %s\n", mrFields.Branch) - } - if mrFields.Target != "" { - fmt.Printf(" Target: %s\n", mrFields.Target) - } - if mrFields.SourceIssue != "" { - fmt.Printf(" Source Issue: %s\n", mrFields.SourceIssue) - } - if mrFields.Worker != "" { - fmt.Printf(" Worker: %s\n", mrFields.Worker) - } - if mrFields.Rig != "" { - fmt.Printf(" Rig: %s\n", mrFields.Rig) - } - if mrFields.MergeCommit != "" { - fmt.Printf(" Merge Commit: %s\n", mrFields.MergeCommit) - } - if mrFields.CloseReason != "" { - fmt.Printf(" Close Reason: %s\n", mrFields.CloseReason) - } - } - - // Dependencies (what this MR is waiting on) - if len(issue.Dependencies) > 0 { - fmt.Printf("\n%s\n", style.Bold.Render("Waiting On")) - for _, dep := range issue.Dependencies { - statusIcon := getStatusIcon(dep.Status) - fmt.Printf(" %s %s: %s %s\n", - statusIcon, - dep.ID, - truncateString(dep.Title, 50), - style.Dim.Render(fmt.Sprintf("[%s]", dep.Status))) - } - } - - // Blockers (what's waiting on this MR) - if len(issue.Dependents) > 0 { - fmt.Printf("\n%s\n", style.Bold.Render("Blocking")) - for _, dep := range issue.Dependents { - statusIcon := getStatusIcon(dep.Status) - fmt.Printf(" %s %s: %s %s\n", - statusIcon, - dep.ID, - truncateString(dep.Title, 50), - style.Dim.Render(fmt.Sprintf("[%s]", dep.Status))) - } - } - - // Description (if present and not just MR fields) - desc := getDescriptionWithoutMRFields(issue.Description) - if desc != "" { - fmt.Printf("\n%s\n", style.Bold.Render("Notes")) - // Indent each line - for _, line := range strings.Split(desc, "\n") { - fmt.Printf(" %s\n", line) - } - } - - return nil -} - -// formatStatus formats the status with appropriate styling. -func formatStatus(status string) string { - switch status { - case "open": - return style.Info.Render("● open") - case "in_progress": - return style.Bold.Render("▶ in_progress") - case "closed": - return style.Dim.Render("✓ closed") - default: - return status - } -} - -// getStatusIcon returns an icon for the given status. -func getStatusIcon(status string) string { - switch status { - case "open": - return "○" - case "in_progress": - return "▶" - case "closed": - return "✓" - default: - return "•" - } -} - -// formatTimeAgo formats a timestamp as a relative time string. -func formatTimeAgo(timestamp string) string { - // Try parsing common formats - formats := []string{ - time.RFC3339, - "2006-01-02T15:04:05Z", - "2006-01-02T15:04:05", - "2006-01-02 15:04:05", - "2006-01-02", - } - - var t time.Time - var err error - for _, format := range formats { - t, err = time.Parse(format, timestamp) - if err == nil { - break - } - } - if err != nil { - return "" // Can't parse, return empty - } - - d := time.Since(t) - if d < 0 { - return style.Dim.Render("(in the future)") - } - - var ago string - if d < time.Minute { - ago = fmt.Sprintf("%ds ago", int(d.Seconds())) - } else if d < time.Hour { - ago = fmt.Sprintf("%dm ago", int(d.Minutes())) - } else if d < 24*time.Hour { - ago = fmt.Sprintf("%dh ago", int(d.Hours())) - } else { - ago = fmt.Sprintf("%dd ago", int(d.Hours()/24)) - } - - return style.Dim.Render("(" + ago + ")") -} - -// truncateString truncates a string to maxLen, adding "..." if truncated. -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} - -// getDescriptionWithoutMRFields returns the description with MR field lines removed. -func getDescriptionWithoutMRFields(description string) string { - if description == "" { - return "" - } - - // Known MR field keys (lowercase) - mrKeys := map[string]bool{ - "branch": true, - "target": true, - "source_issue": true, - "source-issue": true, - "sourceissue": true, - "worker": true, - "rig": true, - "merge_commit": true, - "merge-commit": true, - "mergecommit": true, - "close_reason": true, - "close-reason": true, - "closereason": true, - "type": true, - } - - var lines []string - for _, line := range strings.Split(description, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - lines = append(lines, line) - continue - } - - // Check if this is an MR field line - colonIdx := strings.Index(trimmed, ":") - if colonIdx != -1 { - key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx])) - if mrKeys[key] { - continue // Skip MR field lines - } - } - - lines = append(lines, line) - } - - // Trim leading/trailing blank lines - result := strings.Join(lines, "\n") - result = strings.TrimSpace(result) - return result -} - -// runMqIntegrationCreate creates an integration branch for an epic. -func runMqIntegrationCreate(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) - - // 1. Verify epic exists - epic, err := bd.Show(epicID) - if err != nil { - if err == beads.ErrNotFound { - return fmt.Errorf("epic '%s' not found", epicID) - } - return fmt.Errorf("fetching epic: %w", err) - } - - // Verify it's actually an epic - if epic.Type != "epic" { - return fmt.Errorf("'%s' is a %s, not an epic", epicID, epic.Type) - } - - // Build integration branch name - branchName := "integration/" + epicID - - // Initialize git for the rig - g := git.NewGit(r.Path) - - // Check if integration branch already exists locally - exists, err := g.BranchExists(branchName) - if err != nil { - return fmt.Errorf("checking branch existence: %w", err) - } - if exists { - return fmt.Errorf("integration branch '%s' already exists locally", branchName) - } - - // Check if branch exists on remote - remoteExists, err := g.RemoteBranchExists("origin", branchName) - if err != nil { - // Log warning but continue - remote check isn't critical - fmt.Printf(" %s\n", style.Dim.Render("(could not check remote, continuing)")) - } - if remoteExists { - return fmt.Errorf("integration branch '%s' already exists on origin", branchName) - } - - // Ensure we have latest main - fmt.Printf("Fetching latest from origin...\n") - if err := g.Fetch("origin"); err != nil { - return fmt.Errorf("fetching from origin: %w", err) - } - - // 2. Create branch from origin/main - fmt.Printf("Creating branch '%s' from main...\n", branchName) - if err := g.CreateBranchFrom(branchName, "origin/main"); err != nil { - return fmt.Errorf("creating branch: %w", err) - } - - // 3. Push to origin - fmt.Printf("Pushing to origin...\n") - if err := g.Push("origin", branchName, false); err != nil { - // Clean up local branch on push failure - _ = g.DeleteBranch(branchName, true) - return fmt.Errorf("pushing to origin: %w", err) - } - - // 4. Store integration branch info in epic metadata - // Update the epic's description to include the integration branch info - newDesc := addIntegrationBranchField(epic.Description, branchName) - if newDesc != epic.Description { - if err := bd.Update(epicID, beads.UpdateOptions{Description: &newDesc}); err != nil { - // Non-fatal - branch was created, just metadata update failed - fmt.Printf(" %s\n", style.Dim.Render("(warning: could not update epic metadata)")) - } - } - - // Success output - fmt.Printf("\n%s Created integration branch\n", style.Bold.Render("✓")) - fmt.Printf(" Epic: %s\n", epicID) - fmt.Printf(" Branch: %s\n", branchName) - fmt.Printf(" From: main\n") - fmt.Printf("\n Future MRs for this epic's children can target:\n") - fmt.Printf(" gt mq submit --epic %s\n", epicID) - - return nil -} - -// addIntegrationBranchField adds or updates the integration_branch field in a description. -func addIntegrationBranchField(description, branchName string) string { - fieldLine := "integration_branch: " + branchName - - // If description is empty, just return the field - if description == "" { - return fieldLine - } - - // Check if integration_branch field already exists - lines := strings.Split(description, "\n") - var newLines []string - found := false - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(strings.ToLower(trimmed), "integration_branch:") { - // Replace existing field - newLines = append(newLines, fieldLine) - found = true - } else { - newLines = append(newLines, line) - } - } - - if !found { - // Add field at the beginning - newLines = append([]string{fieldLine}, newLines...) - } - - return strings.Join(newLines, "\n") -} - -// runMqIntegrationLand merges an integration branch to main. -func runMqIntegrationLand(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 and git for the rig - bd := beads.New(r.Path) - g := git.NewGit(r.Path) - - // Build integration branch name - branchName := "integration/" + epicID - - // Show what we're about to do - if mqIntegrationLandDryRun { - fmt.Printf("%s Dry run - no changes will be made\n\n", style.Bold.Render("🔍")) - } - - // 1. Verify epic exists - epic, err := bd.Show(epicID) - if err != nil { - if err == beads.ErrNotFound { - return fmt.Errorf("epic '%s' not found", epicID) - } - return fmt.Errorf("fetching epic: %w", err) - } - - if epic.Type != "epic" { - return fmt.Errorf("'%s' is a %s, not an epic", epicID, epic.Type) - } - - fmt.Printf("Landing integration branch for epic: %s\n", epicID) - fmt.Printf(" Title: %s\n\n", epic.Title) - - // 2. Verify integration branch exists - fmt.Printf("Checking integration branch...\n") - exists, err := g.BranchExists(branchName) - if err != nil { - return fmt.Errorf("checking branch existence: %w", err) - } - - // Also check remote if local doesn't exist - if !exists { - remoteExists, err := g.RemoteBranchExists("origin", branchName) - if err != nil { - return fmt.Errorf("checking remote branch: %w", err) - } - if !remoteExists { - return fmt.Errorf("integration branch '%s' does not exist (locally or on origin)", branchName) - } - // Fetch and create local tracking branch - fmt.Printf("Fetching integration branch from origin...\n") - if err := g.FetchBranch("origin", branchName); err != nil { - return fmt.Errorf("fetching branch: %w", err) - } - } - fmt.Printf(" %s Branch exists\n", style.Bold.Render("✓")) - - // 3. Verify all MRs targeting this integration branch are merged - fmt.Printf("Checking open merge requests...\n") - openMRs, err := findOpenMRsForIntegration(bd, branchName) - if err != nil { - return fmt.Errorf("checking open MRs: %w", err) - } - - if len(openMRs) > 0 { - fmt.Printf("\n %s Open merge requests targeting %s:\n", style.Bold.Render("⚠"), branchName) - for _, mr := range openMRs { - fmt.Printf(" - %s: %s\n", mr.ID, mr.Title) - } - fmt.Println() - - if !mqIntegrationLandForce { - return fmt.Errorf("cannot land: %d open MRs (use --force to override)", len(openMRs)) - } - fmt.Printf(" %s Proceeding anyway (--force)\n", style.Dim.Render("⚠")) - } else { - fmt.Printf(" %s No open MRs targeting integration branch\n", style.Bold.Render("✓")) - } - - // Dry run stops here - if mqIntegrationLandDryRun { - fmt.Printf("\n%s Dry run complete. Would perform:\n", style.Bold.Render("🔍")) - fmt.Printf(" 1. Merge %s to main (--no-ff)\n", branchName) - if !mqIntegrationLandSkipTests { - fmt.Printf(" 2. Run tests on main\n") - } - fmt.Printf(" 3. Push main to origin\n") - fmt.Printf(" 4. Delete integration branch (local and remote)\n") - fmt.Printf(" 5. Update epic status to closed\n") - return nil - } - - // Ensure working directory is clean - status, err := g.Status() - if err != nil { - return fmt.Errorf("checking git status: %w", err) - } - if !status.Clean { - return fmt.Errorf("working directory is not clean; please commit or stash changes") - } - - // Fetch latest - fmt.Printf("Fetching latest from origin...\n") - if err := g.Fetch("origin"); err != nil { - return fmt.Errorf("fetching from origin: %w", err) - } - - // 4. Checkout main and merge integration branch - fmt.Printf("Checking out main...\n") - if err := g.Checkout("main"); err != nil { - return fmt.Errorf("checking out main: %w", err) - } - - // Pull latest main - if err := g.Pull("origin", "main"); err != nil { - // Non-fatal if pull fails (e.g., first time) - fmt.Printf(" %s\n", style.Dim.Render("(pull from origin/main skipped)")) - } - - // Merge with --no-ff - fmt.Printf("Merging %s to main...\n", branchName) - mergeMsg := fmt.Sprintf("Merge %s: %s\n\nEpic: %s", branchName, epic.Title, epicID) - if err := g.MergeNoFF("origin/"+branchName, mergeMsg); err != nil { - // Abort merge on failure - _ = g.AbortMerge() - return fmt.Errorf("merge failed: %w", err) - } - fmt.Printf(" %s Merged successfully\n", style.Bold.Render("✓")) - - // 5. Run tests (if configured and not skipped) - if !mqIntegrationLandSkipTests { - testCmd := getTestCommand(r.Path) - if testCmd != "" { - fmt.Printf("Running tests: %s\n", testCmd) - if err := runTestCommand(r.Path, testCmd); err != nil { - // Tests failed - reset main - fmt.Printf(" %s Tests failed, resetting main...\n", style.Bold.Render("✗")) - _ = g.Checkout("main") - resetErr := resetHard(g, "HEAD~1") - if resetErr != nil { - return fmt.Errorf("tests failed and could not reset: %w (test error: %v)", resetErr, err) - } - return fmt.Errorf("tests failed: %w", err) - } - fmt.Printf(" %s Tests passed\n", style.Bold.Render("✓")) - } else { - fmt.Printf(" %s\n", style.Dim.Render("(no test command configured)")) - } - } else { - fmt.Printf(" %s\n", style.Dim.Render("(tests skipped)")) - } - - // 6. Push to origin - fmt.Printf("Pushing main to origin...\n") - if err := g.Push("origin", "main", false); err != nil { - // Reset on push failure - resetErr := resetHard(g, "HEAD~1") - if resetErr != nil { - return fmt.Errorf("push failed and could not reset: %w (push error: %v)", resetErr, err) - } - return fmt.Errorf("push failed: %w", err) - } - fmt.Printf(" %s Pushed to origin\n", style.Bold.Render("✓")) - - // 7. Delete integration branch - fmt.Printf("Deleting integration branch...\n") - // Delete remote first - if err := g.DeleteRemoteBranch("origin", branchName); err != nil { - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(could not delete remote branch: %v)", err))) - } else { - fmt.Printf(" %s Deleted from origin\n", style.Bold.Render("✓")) - } - // Delete local - if err := g.DeleteBranch(branchName, true); err != nil { - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(could not delete local branch: %v)", err))) - } else { - fmt.Printf(" %s Deleted locally\n", style.Bold.Render("✓")) - } - - // 8. Update epic status - fmt.Printf("Updating epic status...\n") - if err := bd.Close(epicID); err != nil { - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(could not close epic: %v)", err))) - } else { - fmt.Printf(" %s Epic closed\n", style.Bold.Render("✓")) - } - - // Success output - fmt.Printf("\n%s Successfully landed integration branch\n", style.Bold.Render("✓")) - fmt.Printf(" Epic: %s\n", epicID) - fmt.Printf(" Branch: %s → main\n", branchName) - - return nil -} - -// findOpenMRsForIntegration finds all open merge requests targeting an integration branch. -func findOpenMRsForIntegration(bd *beads.Beads, targetBranch string) ([]*beads.Issue, error) { - // List all open merge requests - opts := beads.ListOptions{ - Type: "merge-request", - Status: "open", - } - allMRs, err := bd.List(opts) - if err != nil { - return nil, err - } - - // Filter to those targeting this integration branch - var openMRs []*beads.Issue - for _, mr := range allMRs { - fields := beads.ParseMRFields(mr) - if fields != nil && fields.Target == targetBranch { - openMRs = append(openMRs, mr) - } - } - - return openMRs, nil -} - -// getTestCommand returns the test command from rig config. -func getTestCommand(rigPath string) string { - configPath := filepath.Join(rigPath, "config.json") - data, err := os.ReadFile(configPath) - if err != nil { - // Try .gastown/config.json as fallback - configPath = filepath.Join(rigPath, ".gastown", "config.json") - data, err = os.ReadFile(configPath) - if err != nil { - return "" - } - } - - var rawConfig struct { - MergeQueue struct { - TestCommand string `json:"test_command"` - } `json:"merge_queue"` - TestCommand string `json:"test_command"` // Legacy fallback - } - if err := json.Unmarshal(data, &rawConfig); err != nil { - return "" - } - - if rawConfig.MergeQueue.TestCommand != "" { - return rawConfig.MergeQueue.TestCommand - } - return rawConfig.TestCommand -} - -// runTestCommand executes a test command in the given directory. -func runTestCommand(workDir, testCmd string) error { - parts := strings.Fields(testCmd) - if len(parts) == 0 { - return nil - } - - cmd := exec.Command(parts[0], parts[1:]...) - cmd.Dir = workDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() -} - -// resetHard performs a git reset --hard to the given ref. -func resetHard(g *git.Git, ref string) error { - // We need to use the git package, but it doesn't have a Reset method - // For now, use the internal run method via Checkout workaround - // This is a bit of a hack but works for now - cmd := exec.Command("git", "reset", "--hard", ref) - cmd.Dir = g.WorkDir() - return cmd.Run() -} - -// detectIntegrationBranch checks if an issue is a child of an epic that has an integration branch. -// Returns the integration branch target (e.g., "integration/gt-epic") if found, or "" if not. -func detectIntegrationBranch(bd *beads.Beads, g *git.Git, issueID string) (string, error) { - // Get the source issue - issue, err := bd.Show(issueID) - if err != nil { - return "", fmt.Errorf("looking up issue %s: %w", issueID, err) - } - - // Check if issue has a parent - if issue.Parent == "" { - return "", nil // No parent, no integration branch - } - - // Get the parent issue - parent, err := bd.Show(issue.Parent) - if err != nil { - return "", fmt.Errorf("looking up parent %s: %w", issue.Parent, err) - } - - // Check if parent is an epic - if parent.Type != "epic" { - return "", nil // Parent is not an epic - } - - // Check if integration branch exists - integrationBranch := "integration/" + parent.ID - - // Check local first (faster) - exists, err := g.BranchExists(integrationBranch) - if err != nil { - return "", fmt.Errorf("checking local branch: %w", err) - } - if exists { - return integrationBranch, nil - } - - // Check remote - exists, err = g.RemoteBranchExists("origin", integrationBranch) - if err != nil { - // Remote check failure is non-fatal - return "", nil - } - if exists { - return integrationBranch, nil - } - - 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 -} diff --git a/internal/cmd/mq_integration.go b/internal/cmd/mq_integration.go new file mode 100644 index 00000000..ed6c93d0 --- /dev/null +++ b/internal/cmd/mq_integration.go @@ -0,0 +1,609 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +// 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"` +} + +// runMqIntegrationCreate creates an integration branch for an epic. +func runMqIntegrationCreate(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) + + // 1. Verify epic exists + epic, err := bd.Show(epicID) + if err != nil { + if err == beads.ErrNotFound { + return fmt.Errorf("epic '%s' not found", epicID) + } + return fmt.Errorf("fetching epic: %w", err) + } + + // Verify it's actually an epic + if epic.Type != "epic" { + return fmt.Errorf("'%s' is a %s, not an epic", epicID, epic.Type) + } + + // Build integration branch name + branchName := "integration/" + epicID + + // Initialize git for the rig + g := git.NewGit(r.Path) + + // Check if integration branch already exists locally + exists, err := g.BranchExists(branchName) + if err != nil { + return fmt.Errorf("checking branch existence: %w", err) + } + if exists { + return fmt.Errorf("integration branch '%s' already exists locally", branchName) + } + + // Check if branch exists on remote + remoteExists, err := g.RemoteBranchExists("origin", branchName) + if err != nil { + // Log warning but continue - remote check isn't critical + fmt.Printf(" %s\n", style.Dim.Render("(could not check remote, continuing)")) + } + if remoteExists { + return fmt.Errorf("integration branch '%s' already exists on origin", branchName) + } + + // Ensure we have latest main + fmt.Printf("Fetching latest from origin...\n") + if err := g.Fetch("origin"); err != nil { + return fmt.Errorf("fetching from origin: %w", err) + } + + // 2. Create branch from origin/main + fmt.Printf("Creating branch '%s' from main...\n", branchName) + if err := g.CreateBranchFrom(branchName, "origin/main"); err != nil { + return fmt.Errorf("creating branch: %w", err) + } + + // 3. Push to origin + fmt.Printf("Pushing to origin...\n") + if err := g.Push("origin", branchName, false); err != nil { + // Clean up local branch on push failure + _ = g.DeleteBranch(branchName, true) + return fmt.Errorf("pushing to origin: %w", err) + } + + // 4. Store integration branch info in epic metadata + // Update the epic's description to include the integration branch info + newDesc := addIntegrationBranchField(epic.Description, branchName) + if newDesc != epic.Description { + if err := bd.Update(epicID, beads.UpdateOptions{Description: &newDesc}); err != nil { + // Non-fatal - branch was created, just metadata update failed + fmt.Printf(" %s\n", style.Dim.Render("(warning: could not update epic metadata)")) + } + } + + // Success output + fmt.Printf("\n%s Created integration branch\n", style.Bold.Render("✓")) + fmt.Printf(" Epic: %s\n", epicID) + fmt.Printf(" Branch: %s\n", branchName) + fmt.Printf(" From: main\n") + fmt.Printf("\n Future MRs for this epic's children can target:\n") + fmt.Printf(" gt mq submit --epic %s\n", epicID) + + return nil +} + +// addIntegrationBranchField adds or updates the integration_branch field in a description. +func addIntegrationBranchField(description, branchName string) string { + fieldLine := "integration_branch: " + branchName + + // If description is empty, just return the field + if description == "" { + return fieldLine + } + + // Check if integration_branch field already exists + lines := strings.Split(description, "\n") + var newLines []string + found := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), "integration_branch:") { + // Replace existing field + newLines = append(newLines, fieldLine) + found = true + } else { + newLines = append(newLines, line) + } + } + + if !found { + // Add field at the beginning + newLines = append([]string{fieldLine}, newLines...) + } + + return strings.Join(newLines, "\n") +} + +// runMqIntegrationLand merges an integration branch to main. +func runMqIntegrationLand(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 and git for the rig + bd := beads.New(r.Path) + g := git.NewGit(r.Path) + + // Build integration branch name + branchName := "integration/" + epicID + + // Show what we're about to do + if mqIntegrationLandDryRun { + fmt.Printf("%s Dry run - no changes will be made\n\n", style.Bold.Render("🔍")) + } + + // 1. Verify epic exists + epic, err := bd.Show(epicID) + if err != nil { + if err == beads.ErrNotFound { + return fmt.Errorf("epic '%s' not found", epicID) + } + return fmt.Errorf("fetching epic: %w", err) + } + + if epic.Type != "epic" { + return fmt.Errorf("'%s' is a %s, not an epic", epicID, epic.Type) + } + + fmt.Printf("Landing integration branch for epic: %s\n", epicID) + fmt.Printf(" Title: %s\n\n", epic.Title) + + // 2. Verify integration branch exists + fmt.Printf("Checking integration branch...\n") + exists, err := g.BranchExists(branchName) + if err != nil { + return fmt.Errorf("checking branch existence: %w", err) + } + + // Also check remote if local doesn't exist + if !exists { + remoteExists, err := g.RemoteBranchExists("origin", branchName) + if err != nil { + return fmt.Errorf("checking remote branch: %w", err) + } + if !remoteExists { + return fmt.Errorf("integration branch '%s' does not exist (locally or on origin)", branchName) + } + // Fetch and create local tracking branch + fmt.Printf("Fetching integration branch from origin...\n") + if err := g.FetchBranch("origin", branchName); err != nil { + return fmt.Errorf("fetching branch: %w", err) + } + } + fmt.Printf(" %s Branch exists\n", style.Bold.Render("✓")) + + // 3. Verify all MRs targeting this integration branch are merged + fmt.Printf("Checking open merge requests...\n") + openMRs, err := findOpenMRsForIntegration(bd, branchName) + if err != nil { + return fmt.Errorf("checking open MRs: %w", err) + } + + if len(openMRs) > 0 { + fmt.Printf("\n %s Open merge requests targeting %s:\n", style.Bold.Render("⚠"), branchName) + for _, mr := range openMRs { + fmt.Printf(" - %s: %s\n", mr.ID, mr.Title) + } + fmt.Println() + + if !mqIntegrationLandForce { + return fmt.Errorf("cannot land: %d open MRs (use --force to override)", len(openMRs)) + } + fmt.Printf(" %s Proceeding anyway (--force)\n", style.Dim.Render("⚠")) + } else { + fmt.Printf(" %s No open MRs targeting integration branch\n", style.Bold.Render("✓")) + } + + // Dry run stops here + if mqIntegrationLandDryRun { + fmt.Printf("\n%s Dry run complete. Would perform:\n", style.Bold.Render("🔍")) + fmt.Printf(" 1. Merge %s to main (--no-ff)\n", branchName) + if !mqIntegrationLandSkipTests { + fmt.Printf(" 2. Run tests on main\n") + } + fmt.Printf(" 3. Push main to origin\n") + fmt.Printf(" 4. Delete integration branch (local and remote)\n") + fmt.Printf(" 5. Update epic status to closed\n") + return nil + } + + // Ensure working directory is clean + status, err := g.Status() + if err != nil { + return fmt.Errorf("checking git status: %w", err) + } + if !status.Clean { + return fmt.Errorf("working directory is not clean; please commit or stash changes") + } + + // Fetch latest + fmt.Printf("Fetching latest from origin...\n") + if err := g.Fetch("origin"); err != nil { + return fmt.Errorf("fetching from origin: %w", err) + } + + // 4. Checkout main and merge integration branch + fmt.Printf("Checking out main...\n") + if err := g.Checkout("main"); err != nil { + return fmt.Errorf("checking out main: %w", err) + } + + // Pull latest main + if err := g.Pull("origin", "main"); err != nil { + // Non-fatal if pull fails (e.g., first time) + fmt.Printf(" %s\n", style.Dim.Render("(pull from origin/main skipped)")) + } + + // Merge with --no-ff + fmt.Printf("Merging %s to main...\n", branchName) + mergeMsg := fmt.Sprintf("Merge %s: %s\n\nEpic: %s", branchName, epic.Title, epicID) + if err := g.MergeNoFF("origin/"+branchName, mergeMsg); err != nil { + // Abort merge on failure + _ = g.AbortMerge() + return fmt.Errorf("merge failed: %w", err) + } + fmt.Printf(" %s Merged successfully\n", style.Bold.Render("✓")) + + // 5. Run tests (if configured and not skipped) + if !mqIntegrationLandSkipTests { + testCmd := getTestCommand(r.Path) + if testCmd != "" { + fmt.Printf("Running tests: %s\n", testCmd) + if err := runTestCommand(r.Path, testCmd); err != nil { + // Tests failed - reset main + fmt.Printf(" %s Tests failed, resetting main...\n", style.Bold.Render("✗")) + _ = g.Checkout("main") + resetErr := resetHard(g, "HEAD~1") + if resetErr != nil { + return fmt.Errorf("tests failed and could not reset: %w (test error: %v)", resetErr, err) + } + return fmt.Errorf("tests failed: %w", err) + } + fmt.Printf(" %s Tests passed\n", style.Bold.Render("✓")) + } else { + fmt.Printf(" %s\n", style.Dim.Render("(no test command configured)")) + } + } else { + fmt.Printf(" %s\n", style.Dim.Render("(tests skipped)")) + } + + // 6. Push to origin + fmt.Printf("Pushing main to origin...\n") + if err := g.Push("origin", "main", false); err != nil { + // Reset on push failure + resetErr := resetHard(g, "HEAD~1") + if resetErr != nil { + return fmt.Errorf("push failed and could not reset: %w (push error: %v)", resetErr, err) + } + return fmt.Errorf("push failed: %w", err) + } + fmt.Printf(" %s Pushed to origin\n", style.Bold.Render("✓")) + + // 7. Delete integration branch + fmt.Printf("Deleting integration branch...\n") + // Delete remote first + if err := g.DeleteRemoteBranch("origin", branchName); err != nil { + fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(could not delete remote branch: %v)", err))) + } else { + fmt.Printf(" %s Deleted from origin\n", style.Bold.Render("✓")) + } + // Delete local + if err := g.DeleteBranch(branchName, true); err != nil { + fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(could not delete local branch: %v)", err))) + } else { + fmt.Printf(" %s Deleted locally\n", style.Bold.Render("✓")) + } + + // 8. Update epic status + fmt.Printf("Updating epic status...\n") + if err := bd.Close(epicID); err != nil { + fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(could not close epic: %v)", err))) + } else { + fmt.Printf(" %s Epic closed\n", style.Bold.Render("✓")) + } + + // Success output + fmt.Printf("\n%s Successfully landed integration branch\n", style.Bold.Render("✓")) + fmt.Printf(" Epic: %s\n", epicID) + fmt.Printf(" Branch: %s → main\n", branchName) + + return nil +} + +// findOpenMRsForIntegration finds all open merge requests targeting an integration branch. +func findOpenMRsForIntegration(bd *beads.Beads, targetBranch string) ([]*beads.Issue, error) { + // List all open merge requests + opts := beads.ListOptions{ + Type: "merge-request", + Status: "open", + } + allMRs, err := bd.List(opts) + if err != nil { + return nil, err + } + + return filterMRsByTarget(allMRs, targetBranch), nil +} + +// filterMRsByTarget filters merge requests to those targeting a specific branch. +func filterMRsByTarget(mrs []*beads.Issue, targetBranch string) []*beads.Issue { + var result []*beads.Issue + for _, mr := range mrs { + fields := beads.ParseMRFields(mr) + if fields != nil && fields.Target == targetBranch { + result = append(result, mr) + } + } + return result +} + +// getTestCommand returns the test command from rig config. +func getTestCommand(rigPath string) string { + configPath := filepath.Join(rigPath, "config.json") + data, err := os.ReadFile(configPath) + if err != nil { + // Try .gastown/config.json as fallback + configPath = filepath.Join(rigPath, ".gastown", "config.json") + data, err = os.ReadFile(configPath) + if err != nil { + return "" + } + } + + var rawConfig struct { + MergeQueue struct { + TestCommand string `json:"test_command"` + } `json:"merge_queue"` + TestCommand string `json:"test_command"` // Legacy fallback + } + if err := json.Unmarshal(data, &rawConfig); err != nil { + return "" + } + + if rawConfig.MergeQueue.TestCommand != "" { + return rawConfig.MergeQueue.TestCommand + } + return rawConfig.TestCommand +} + +// runTestCommand executes a test command in the given directory. +func runTestCommand(workDir, testCmd string) error { + parts := strings.Fields(testCmd) + if len(parts) == 0 { + return nil + } + + cmd := exec.Command(parts[0], parts[1:]...) + cmd.Dir = workDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// resetHard performs a git reset --hard to the given ref. +func resetHard(g *git.Git, ref string) error { + // We need to use the git package, but it doesn't have a Reset method + // For now, use the internal run method via Checkout workaround + // This is a bit of a hack but works for now + cmd := exec.Command("git", "reset", "--hard", ref) + cmd.Dir = g.WorkDir() + return cmd.Run() +} + +// 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 +} diff --git a/internal/cmd/mq_list.go b/internal/cmd/mq_list.go new file mode 100644 index 00000000..f5663458 --- /dev/null +++ b/internal/cmd/mq_list.go @@ -0,0 +1,206 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" +) + +func runMQList(cmd *cobra.Command, args []string) error { + rigName := args[0] + + _, r, err := getRefineryManager(rigName) + if err != nil { + return err + } + + // Create beads wrapper for the rig + b := beads.New(r.Path) + + // Build list options - query for merge-request type + // Priority -1 means no priority filter (otherwise 0 would filter to P0 only) + opts := beads.ListOptions{ + Type: "merge-request", + Priority: -1, + } + + // Apply status filter if specified + if mqListStatus != "" { + opts.Status = mqListStatus + } else if !mqListReady { + // Default to open if not showing ready + opts.Status = "open" + } + + var issues []*beads.Issue + + if mqListReady { + // Use ready query which filters by no blockers + allReady, err := b.Ready() + if err != nil { + return fmt.Errorf("querying ready MRs: %w", err) + } + // Filter to only merge-request type + for _, issue := range allReady { + if issue.Type == "merge-request" { + issues = append(issues, issue) + } + } + } else { + issues, err = b.List(opts) + if err != nil { + return fmt.Errorf("querying merge queue: %w", err) + } + } + + // Apply additional filters + var filtered []*beads.Issue + for _, issue := range issues { + // Parse MR fields + fields := beads.ParseMRFields(issue) + + // Filter by worker + if mqListWorker != "" { + worker := "" + if fields != nil { + worker = fields.Worker + } + if !strings.EqualFold(worker, mqListWorker) { + continue + } + } + + // Filter by epic (target branch) + if mqListEpic != "" { + target := "" + if fields != nil { + target = fields.Target + } + expectedTarget := "integration/" + mqListEpic + if target != expectedTarget { + continue + } + } + + filtered = append(filtered, issue) + } + + // JSON output + if mqListJSON { + return outputJSON(filtered) + } + + // Human-readable output + fmt.Printf("%s Merge queue for '%s':\n\n", style.Bold.Render("📋"), rigName) + + if len(filtered) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(empty)")) + return nil + } + + // Print header + fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n", + "ID", "STATUS", "PRIORITY", "BRANCH", "WORKER", "AGE") + fmt.Printf(" %s\n", strings.Repeat("-", 90)) + + // Print each MR + for _, issue := range filtered { + fields := beads.ParseMRFields(issue) + + // Determine display status + displayStatus := issue.Status + if issue.Status == "open" { + if len(issue.BlockedBy) > 0 || issue.BlockedByCount > 0 { + displayStatus = "blocked" + } else { + displayStatus = "ready" + } + } + + // Format status with styling + styledStatus := displayStatus + switch displayStatus { + case "ready": + styledStatus = style.Bold.Render("ready") + case "in_progress": + styledStatus = style.Bold.Render("in_progress") + case "blocked": + styledStatus = style.Dim.Render("blocked") + case "closed": + styledStatus = style.Dim.Render("closed") + } + + // Get MR fields + branch := "" + worker := "" + if fields != nil { + branch = fields.Branch + worker = fields.Worker + } + + // Truncate branch if too long + if len(branch) > 30 { + branch = branch[:27] + "..." + } + + // Format priority + priority := fmt.Sprintf("P%d", issue.Priority) + + // Calculate age + age := formatMRAge(issue.CreatedAt) + + // Truncate ID if needed + displayID := issue.ID + if len(displayID) > 12 { + displayID = displayID[:12] + } + + fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n", + displayID, styledStatus, priority, branch, worker, style.Dim.Render(age)) + + // Show blocking info if blocked + if displayStatus == "blocked" && len(issue.BlockedBy) > 0 { + fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf(" (waiting on %s)", issue.BlockedBy[0]))) + } + } + + return nil +} + +// formatMRAge formats the age of an MR from its created_at timestamp. +func formatMRAge(createdAt string) string { + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + // Try other formats + t, err = time.Parse("2006-01-02T15:04:05Z", createdAt) + if err != nil { + return "?" + } + } + + d := time.Since(t) + + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh", int(d.Hours())) + } + return fmt.Sprintf("%dd", int(d.Hours()/24)) +} + +// outputJSON outputs data as JSON. +func outputJSON(data interface{}) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) +} diff --git a/internal/cmd/mq_status.go b/internal/cmd/mq_status.go new file mode 100644 index 00000000..c5e5edf6 --- /dev/null +++ b/internal/cmd/mq_status.go @@ -0,0 +1,357 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" +) + +// MRStatusOutput is the JSON output structure for gt mq status. +type MRStatusOutput struct { + // Core issue fields + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Priority int `json:"priority"` + Type string `json:"type"` + Assignee string `json:"assignee,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ClosedAt string `json:"closed_at,omitempty"` + + // MR-specific fields + Branch string `json:"branch,omitempty"` + Target string `json:"target,omitempty"` + SourceIssue string `json:"source_issue,omitempty"` + Worker string `json:"worker,omitempty"` + Rig string `json:"rig,omitempty"` + MergeCommit string `json:"merge_commit,omitempty"` + CloseReason string `json:"close_reason,omitempty"` + + // Dependencies + DependsOn []DependencyInfo `json:"depends_on,omitempty"` + Blocks []DependencyInfo `json:"blocks,omitempty"` +} + +// DependencyInfo represents a dependency or blocker. +type DependencyInfo struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Priority int `json:"priority"` + Type string `json:"type"` +} + +func runMqStatus(cmd *cobra.Command, args []string) error { + mrID := args[0] + + // Use current working directory for beads operations + // (beads repos are per-rig, not per-workspace) + workDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + + // Initialize beads client + bd := beads.New(workDir) + + // Fetch the issue + issue, err := bd.Show(mrID) + if err != nil { + if err == beads.ErrNotFound { + return fmt.Errorf("merge request '%s' not found", mrID) + } + return fmt.Errorf("fetching merge request: %w", err) + } + + // Parse MR-specific fields from description + mrFields := beads.ParseMRFields(issue) + + // Build output structure + output := MRStatusOutput{ + ID: issue.ID, + Title: issue.Title, + Status: issue.Status, + Priority: issue.Priority, + Type: issue.Type, + Assignee: issue.Assignee, + CreatedAt: issue.CreatedAt, + UpdatedAt: issue.UpdatedAt, + ClosedAt: issue.ClosedAt, + } + + // Add MR fields if present + if mrFields != nil { + output.Branch = mrFields.Branch + output.Target = mrFields.Target + output.SourceIssue = mrFields.SourceIssue + output.Worker = mrFields.Worker + output.Rig = mrFields.Rig + output.MergeCommit = mrFields.MergeCommit + output.CloseReason = mrFields.CloseReason + } + + // Add dependency info from the issue's Dependencies field + for _, dep := range issue.Dependencies { + output.DependsOn = append(output.DependsOn, DependencyInfo{ + ID: dep.ID, + Title: dep.Title, + Status: dep.Status, + Priority: dep.Priority, + Type: dep.Type, + }) + } + + // Add blocker info from the issue's Dependents field + for _, dep := range issue.Dependents { + output.Blocks = append(output.Blocks, DependencyInfo{ + ID: dep.ID, + Title: dep.Title, + Status: dep.Status, + Priority: dep.Priority, + Type: dep.Type, + }) + } + + // JSON output + if mqStatusJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(output) + } + + // Human-readable output + return printMqStatus(issue, mrFields) +} + +// printMqStatus prints detailed MR status in human-readable format. +func printMqStatus(issue *beads.Issue, mrFields *beads.MRFields) error { + // Header + fmt.Printf("%s %s\n", style.Bold.Render("📋 Merge Request:"), issue.ID) + fmt.Printf(" %s\n\n", issue.Title) + + // Status section + fmt.Printf("%s\n", style.Bold.Render("Status")) + statusDisplay := formatStatus(issue.Status) + fmt.Printf(" State: %s\n", statusDisplay) + fmt.Printf(" Priority: P%d\n", issue.Priority) + if issue.Type != "" { + fmt.Printf(" Type: %s\n", issue.Type) + } + if issue.Assignee != "" { + fmt.Printf(" Assignee: %s\n", issue.Assignee) + } + + // Timestamps + fmt.Printf("\n%s\n", style.Bold.Render("Timeline")) + if issue.CreatedAt != "" { + fmt.Printf(" Created: %s %s\n", issue.CreatedAt, formatTimeAgo(issue.CreatedAt)) + } + if issue.UpdatedAt != "" && issue.UpdatedAt != issue.CreatedAt { + fmt.Printf(" Updated: %s %s\n", issue.UpdatedAt, formatTimeAgo(issue.UpdatedAt)) + } + if issue.ClosedAt != "" { + fmt.Printf(" Closed: %s %s\n", issue.ClosedAt, formatTimeAgo(issue.ClosedAt)) + } + + // MR-specific fields + if mrFields != nil { + fmt.Printf("\n%s\n", style.Bold.Render("Merge Details")) + if mrFields.Branch != "" { + fmt.Printf(" Branch: %s\n", mrFields.Branch) + } + if mrFields.Target != "" { + fmt.Printf(" Target: %s\n", mrFields.Target) + } + if mrFields.SourceIssue != "" { + fmt.Printf(" Source Issue: %s\n", mrFields.SourceIssue) + } + if mrFields.Worker != "" { + fmt.Printf(" Worker: %s\n", mrFields.Worker) + } + if mrFields.Rig != "" { + fmt.Printf(" Rig: %s\n", mrFields.Rig) + } + if mrFields.MergeCommit != "" { + fmt.Printf(" Merge Commit: %s\n", mrFields.MergeCommit) + } + if mrFields.CloseReason != "" { + fmt.Printf(" Close Reason: %s\n", mrFields.CloseReason) + } + } + + // Dependencies (what this MR is waiting on) + if len(issue.Dependencies) > 0 { + fmt.Printf("\n%s\n", style.Bold.Render("Waiting On")) + for _, dep := range issue.Dependencies { + statusIcon := getStatusIcon(dep.Status) + fmt.Printf(" %s %s: %s %s\n", + statusIcon, + dep.ID, + truncateString(dep.Title, 50), + style.Dim.Render(fmt.Sprintf("[%s]", dep.Status))) + } + } + + // Blockers (what's waiting on this MR) + if len(issue.Dependents) > 0 { + fmt.Printf("\n%s\n", style.Bold.Render("Blocking")) + for _, dep := range issue.Dependents { + statusIcon := getStatusIcon(dep.Status) + fmt.Printf(" %s %s: %s %s\n", + statusIcon, + dep.ID, + truncateString(dep.Title, 50), + style.Dim.Render(fmt.Sprintf("[%s]", dep.Status))) + } + } + + // Description (if present and not just MR fields) + desc := getDescriptionWithoutMRFields(issue.Description) + if desc != "" { + fmt.Printf("\n%s\n", style.Bold.Render("Notes")) + // Indent each line + for _, line := range strings.Split(desc, "\n") { + fmt.Printf(" %s\n", line) + } + } + + return nil +} + +// formatStatus formats the status with appropriate styling. +func formatStatus(status string) string { + switch status { + case "open": + return style.Info.Render("● open") + case "in_progress": + return style.Bold.Render("▶ in_progress") + case "closed": + return style.Dim.Render("✓ closed") + default: + return status + } +} + +// getStatusIcon returns an icon for the given status. +func getStatusIcon(status string) string { + switch status { + case "open": + return "○" + case "in_progress": + return "▶" + case "closed": + return "✓" + default: + return "•" + } +} + +// formatTimeAgo formats a timestamp as a relative time string. +func formatTimeAgo(timestamp string) string { + // Try parsing common formats + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + } + + var t time.Time + var err error + for _, format := range formats { + t, err = time.Parse(format, timestamp) + if err == nil { + break + } + } + if err != nil { + return "" // Can't parse, return empty + } + + d := time.Since(t) + if d < 0 { + return style.Dim.Render("(in the future)") + } + + var ago string + if d < time.Minute { + ago = fmt.Sprintf("%ds ago", int(d.Seconds())) + } else if d < time.Hour { + ago = fmt.Sprintf("%dm ago", int(d.Minutes())) + } else if d < 24*time.Hour { + ago = fmt.Sprintf("%dh ago", int(d.Hours())) + } else { + ago = fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } + + return style.Dim.Render("(" + ago + ")") +} + +// truncateString truncates a string to maxLen, adding "..." if truncated. +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +// getDescriptionWithoutMRFields returns the description with MR field lines removed. +func getDescriptionWithoutMRFields(description string) string { + if description == "" { + return "" + } + + // Known MR field keys (lowercase) + mrKeys := map[string]bool{ + "branch": true, + "target": true, + "source_issue": true, + "source-issue": true, + "sourceissue": true, + "worker": true, + "rig": true, + "merge_commit": true, + "merge-commit": true, + "mergecommit": true, + "close_reason": true, + "close-reason": true, + "closereason": true, + "type": true, + } + + var lines []string + for _, line := range strings.Split(description, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + lines = append(lines, line) + continue + } + + // Check if this is an MR field line + colonIdx := strings.Index(trimmed, ":") + if colonIdx != -1 { + key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx])) + if mrKeys[key] { + continue // Skip MR field lines + } + } + + lines = append(lines, line) + } + + // Trim leading/trailing blank lines + result := strings.Join(lines, "\n") + result = strings.TrimSpace(result) + return result +} diff --git a/internal/cmd/mq_submit.go b/internal/cmd/mq_submit.go new file mode 100644 index 00000000..fda746ae --- /dev/null +++ b/internal/cmd/mq_submit.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +// branchInfo holds parsed branch information. +type branchInfo struct { + Branch string // Full branch name + Issue string // Issue ID extracted from branch + Worker string // Worker name (polecat name) +} + +// parseBranchName extracts issue ID and worker from a branch name. +// Supports formats: +// - polecat// → issue=, worker= +// - → issue=, worker="" +func parseBranchName(branch string) branchInfo { + info := branchInfo{Branch: branch} + + // Try polecat// format + if strings.HasPrefix(branch, "polecat/") { + parts := strings.SplitN(branch, "/", 3) + if len(parts) == 3 { + info.Worker = parts[1] + info.Issue = parts[2] + return info + } + } + + // Try to find an issue ID pattern in the branch name + // Common patterns: prefix-xxx, prefix-xxx.n (subtask) + issuePattern := regexp.MustCompile(`([a-z]+-[a-z0-9]+(?:\.[0-9]+)?)`) + if matches := issuePattern.FindStringSubmatch(branch); len(matches) > 1 { + info.Issue = matches[1] + } + + return info +} + +func runMqSubmit(cmd *cobra.Command, args []string) error { + // Find workspace + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Find current rig + rigName, _, err := findCurrentRig(townRoot) + if err != nil { + return err + } + + // Initialize git for the current directory + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + g := git.NewGit(cwd) + + // Get current branch + branch := mqSubmitBranch + if branch == "" { + branch, err = g.CurrentBranch() + if err != nil { + return fmt.Errorf("getting current branch: %w", err) + } + } + + if branch == "main" || branch == "master" { + return fmt.Errorf("cannot submit main/master branch to merge queue") + } + + // Parse branch info + info := parseBranchName(branch) + + // Override with explicit flags + issueID := mqSubmitIssue + if issueID == "" { + issueID = info.Issue + } + worker := info.Worker + + if issueID == "" { + return fmt.Errorf("cannot determine source issue from branch '%s'; use --issue to specify", branch) + } + + // Initialize beads for looking up source issue + bd := beads.New(cwd) + + // Determine target branch + target := "main" + if mqSubmitEpic != "" { + // Explicit --epic flag takes precedence + target = "integration/" + mqSubmitEpic + } else { + // Auto-detect: check if source issue has a parent epic with an integration branch + autoTarget, err := detectIntegrationBranch(bd, g, issueID) + if err != nil { + // Non-fatal: log and continue with main as target + fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(note: %v)", err))) + } else if autoTarget != "" { + target = autoTarget + } + } + + // Get source issue for priority inheritance + var priority int + if mqSubmitPriority >= 0 { + priority = mqSubmitPriority + } else { + // Try to inherit from source issue + sourceIssue, err := bd.Show(issueID) + if err != nil { + // Issue not found, use default priority + priority = 2 + } else { + priority = sourceIssue.Priority + } + } + + // Build title + title := fmt.Sprintf("Merge: %s", issueID) + + // Build description with MR fields + mrFields := &beads.MRFields{ + Branch: branch, + Target: target, + SourceIssue: issueID, + Worker: worker, + Rig: rigName, + } + description := beads.FormatMRFields(mrFields) + + // Create the merge-request issue + createOpts := beads.CreateOptions{ + Title: title, + Type: "merge-request", + Priority: priority, + Description: description, + } + + issue, err := bd.Create(createOpts) + if err != nil { + return fmt.Errorf("creating merge request: %w", err) + } + + // Success output + fmt.Printf("%s Created merge request\n", style.Bold.Render("✓")) + fmt.Printf(" MR ID: %s\n", style.Bold.Render(issue.ID)) + fmt.Printf(" Source: %s\n", branch) + fmt.Printf(" Target: %s\n", target) + fmt.Printf(" Issue: %s\n", issueID) + if worker != "" { + fmt.Printf(" Worker: %s\n", worker) + } + fmt.Printf(" Priority: P%d\n", priority) + + return nil +} + +// detectIntegrationBranch checks if an issue is a child of an epic that has an integration branch. +// Returns the integration branch target (e.g., "integration/gt-epic") if found, or "" if not. +func detectIntegrationBranch(bd *beads.Beads, g *git.Git, issueID string) (string, error) { + // Get the source issue + issue, err := bd.Show(issueID) + if err != nil { + return "", fmt.Errorf("looking up issue %s: %w", issueID, err) + } + + // Check if issue has a parent + if issue.Parent == "" { + return "", nil // No parent, no integration branch + } + + // Get the parent issue + parent, err := bd.Show(issue.Parent) + if err != nil { + return "", fmt.Errorf("looking up parent %s: %w", issue.Parent, err) + } + + // Check if parent is an epic + if parent.Type != "epic" { + return "", nil // Parent is not an epic + } + + // Check if integration branch exists + integrationBranch := "integration/" + parent.ID + + // Check local first (faster) + exists, err := g.BranchExists(integrationBranch) + if err != nil { + return "", fmt.Errorf("checking local branch: %w", err) + } + if exists { + return integrationBranch, nil + } + + // Check remote + exists, err = g.RemoteBranchExists("origin", integrationBranch) + if err != nil { + // Remote check failure is non-fatal + return "", nil + } + if exists { + return integrationBranch, nil + } + + return "", nil // No integration branch found +} diff --git a/internal/cmd/mq_test.go b/internal/cmd/mq_test.go index 2e530afc..3501f3aa 100644 --- a/internal/cmd/mq_test.go +++ b/internal/cmd/mq_test.go @@ -2,6 +2,8 @@ package cmd import ( "testing" + + "github.com/steveyegge/gastown/internal/beads" ) func TestAddIntegrationBranchField(t *testing.T) { @@ -212,3 +214,223 @@ func TestTruncateString(t *testing.T) { }) } } + +func TestFormatStatus(t *testing.T) { + tests := []struct { + name string + status string + want string // We check for substring since styling adds ANSI codes + }{ + { + name: "open status", + status: "open", + want: "open", + }, + { + name: "in_progress status", + status: "in_progress", + want: "in_progress", + }, + { + name: "closed status", + status: "closed", + want: "closed", + }, + { + name: "unknown status", + status: "pending", + want: "pending", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatStatus(tt.status) + if got == "" { + t.Errorf("formatStatus(%q) returned empty string", tt.status) + } + // The result contains ANSI codes, so just check the status text is present + if !contains(got, tt.want) { + t.Errorf("formatStatus(%q) = %q, should contain %q", tt.status, got, tt.want) + } + }) + } +} + +func TestGetStatusIcon(t *testing.T) { + tests := []struct { + status string + want string + }{ + {"open", "○"}, + {"in_progress", "▶"}, + {"closed", "✓"}, + {"unknown", "•"}, + {"", "•"}, + } + + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + got := getStatusIcon(tt.status) + if got != tt.want { + t.Errorf("getStatusIcon(%q) = %q, want %q", tt.status, got, tt.want) + } + }) + } +} + +func TestFormatTimeAgo(t *testing.T) { + tests := []struct { + name string + timestamp string + wantEmpty bool + }{ + { + name: "RFC3339 format", + timestamp: "2025-01-01T12:00:00Z", + wantEmpty: false, + }, + { + name: "RFC3339 with timezone", + timestamp: "2025-01-01T12:00:00-08:00", + wantEmpty: false, + }, + { + name: "date only format", + timestamp: "2025-01-01", + wantEmpty: false, + }, + { + name: "datetime without Z", + timestamp: "2025-01-01T12:00:00", + wantEmpty: false, + }, + { + name: "invalid format returns empty", + timestamp: "not-a-date", + wantEmpty: true, + }, + { + name: "empty string returns empty", + timestamp: "", + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatTimeAgo(tt.timestamp) + if tt.wantEmpty && got != "" { + t.Errorf("formatTimeAgo(%q) = %q, want empty", tt.timestamp, got) + } + if !tt.wantEmpty && got == "" { + t.Errorf("formatTimeAgo(%q) returned empty, want non-empty", tt.timestamp) + } + }) + } +} + +// contains checks if s contains substr (helper for styled output) +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestFilterMRsByTarget(t *testing.T) { + // Create test MRs with different targets + mrs := []*beads.Issue{ + makeTestMR("mr-1", "polecat/Nux/gt-001", "integration/gt-epic", "Nux", "open"), + makeTestMR("mr-2", "polecat/Toast/gt-002", "main", "Toast", "open"), + makeTestMR("mr-3", "polecat/Able/gt-003", "integration/gt-epic", "Able", "open"), + makeTestMR("mr-4", "polecat/Baker/gt-004", "integration/gt-other", "Baker", "open"), + } + + tests := []struct { + name string + targetBranch string + wantCount int + wantIDs []string + }{ + { + name: "filter to integration/gt-epic", + targetBranch: "integration/gt-epic", + wantCount: 2, + wantIDs: []string{"mr-1", "mr-3"}, + }, + { + name: "filter to main", + targetBranch: "main", + wantCount: 1, + wantIDs: []string{"mr-2"}, + }, + { + name: "filter to non-existent branch", + targetBranch: "integration/no-such-epic", + wantCount: 0, + wantIDs: []string{}, + }, + { + name: "filter to other integration branch", + targetBranch: "integration/gt-other", + wantCount: 1, + wantIDs: []string{"mr-4"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterMRsByTarget(mrs, tt.targetBranch) + if len(got) != tt.wantCount { + t.Errorf("filterMRsByTarget() returned %d MRs, want %d", len(got), tt.wantCount) + } + + // Verify correct IDs + gotIDs := make(map[string]bool) + for _, mr := range got { + gotIDs[mr.ID] = true + } + for _, wantID := range tt.wantIDs { + if !gotIDs[wantID] { + t.Errorf("filterMRsByTarget() missing expected MR %s", wantID) + } + } + }) + } +} + +func TestFilterMRsByTarget_EmptyInput(t *testing.T) { + got := filterMRsByTarget(nil, "integration/gt-epic") + if got != nil { + t.Errorf("filterMRsByTarget(nil) = %v, want nil", got) + } + + got = filterMRsByTarget([]*beads.Issue{}, "integration/gt-epic") + if len(got) != 0 { + t.Errorf("filterMRsByTarget([]) = %v, want empty slice", got) + } +} + +func TestFilterMRsByTarget_NoMRFields(t *testing.T) { + // Issue without MR fields in description + plainIssue := &beads.Issue{ + ID: "issue-1", + Title: "Not an MR", + Type: "merge-request", + Status: "open", + Description: "Just a plain description with no MR fields", + } + + got := filterMRsByTarget([]*beads.Issue{plainIssue}, "main") + if len(got) != 0 { + t.Errorf("filterMRsByTarget() should filter out issues without MR fields, got %d", len(got)) + } +} diff --git a/internal/cmd/mq_testutil_test.go b/internal/cmd/mq_testutil_test.go new file mode 100644 index 00000000..786e56d3 --- /dev/null +++ b/internal/cmd/mq_testutil_test.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "github.com/steveyegge/gastown/internal/beads" +) + +// mockBeads is a test double for beads.Beads +type mockBeads struct { + issues map[string]*beads.Issue + listFunc func(opts beads.ListOptions) ([]*beads.Issue, error) + showFunc func(id string) (*beads.Issue, error) + closeFunc func(id string) error +} + +func newMockBeads() *mockBeads { + return &mockBeads{ + issues: make(map[string]*beads.Issue), + } +} + +func (m *mockBeads) addIssue(issue *beads.Issue) { + m.issues[issue.ID] = issue +} + +func (m *mockBeads) Show(id string) (*beads.Issue, error) { + if m.showFunc != nil { + return m.showFunc(id) + } + if issue, ok := m.issues[id]; ok { + return issue, nil + } + return nil, beads.ErrNotFound +} + +func (m *mockBeads) List(opts beads.ListOptions) ([]*beads.Issue, error) { + if m.listFunc != nil { + return m.listFunc(opts) + } + var result []*beads.Issue + for _, issue := range m.issues { + // Apply basic filtering + if opts.Type != "" && issue.Type != opts.Type { + continue + } + if opts.Status != "" && issue.Status != opts.Status { + continue + } + result = append(result, issue) + } + return result, nil +} + +func (m *mockBeads) Close(id string) error { + if m.closeFunc != nil { + return m.closeFunc(id) + } + if issue, ok := m.issues[id]; ok { + issue.Status = "closed" + return nil + } + return beads.ErrNotFound +} + +// makeTestIssue creates a test issue with common defaults +func makeTestIssue(id, title, issueType, status string) *beads.Issue { + return &beads.Issue{ + ID: id, + Title: title, + Type: issueType, + Status: status, + Priority: 2, + CreatedAt: "2025-01-01T12:00:00Z", + UpdatedAt: "2025-01-01T12:00:00Z", + } +} + +// makeTestMR creates a test merge request issue +func makeTestMR(id, branch, target, worker string, status string) *beads.Issue { + desc := beads.FormatMRFields(&beads.MRFields{ + Branch: branch, + Target: target, + Worker: worker, + SourceIssue: "gt-src-123", + Rig: "testrig", + }) + return &beads.Issue{ + ID: id, + Title: "Merge: " + branch, + Type: "merge-request", + Status: status, + Priority: 2, + Description: desc, + CreatedAt: "2025-01-01T12:00:00Z", + UpdatedAt: "2025-01-01T12:00:00Z", + } +}