Parent commands (mol, mail, crew, polecat, etc.) previously showed help and exited 0 for unknown subcommands like "gt mol foobar". This masked errors in scripts and confused users. Added requireSubcommand() helper to root.go and applied it to all parent commands. Now unknown subcommands properly error with exit code 1. Example before: gt mol unhook → shows help, exits 0 Example after: gt mol unhook → "Error: unknown command "unhook"", exits 1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
415 lines
13 KiB
Go
415 lines
13 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"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"
|
|
)
|
|
|
|
// MQ command flags
|
|
var (
|
|
// Submit flags
|
|
mqSubmitBranch string
|
|
mqSubmitIssue string
|
|
mqSubmitEpic string
|
|
mqSubmitPriority int
|
|
mqSubmitNoCleanup bool
|
|
|
|
// Retry flags
|
|
mqRetryNow bool
|
|
|
|
// Reject flags
|
|
mqRejectReason string
|
|
mqRejectNotify bool
|
|
|
|
// List command flags
|
|
mqListReady bool
|
|
mqListStatus string
|
|
mqListWorker string
|
|
mqListEpic string
|
|
mqListJSON bool
|
|
|
|
// Status command flags
|
|
mqStatusJSON bool
|
|
|
|
// Integration land flags
|
|
mqIntegrationLandForce bool
|
|
mqIntegrationLandSkipTests bool
|
|
mqIntegrationLandDryRun bool
|
|
|
|
// Integration status flags
|
|
mqIntegrationStatusJSON bool
|
|
)
|
|
|
|
var mqCmd = &cobra.Command{
|
|
Use: "mq",
|
|
GroupID: GroupWork,
|
|
Short: "Merge queue operations",
|
|
RunE: requireSubcommand,
|
|
Long: `Manage the merge queue for a rig.
|
|
|
|
The merge queue tracks work branches from polecats waiting to be merged.
|
|
Use these commands to view, submit, retry, and manage merge requests.`,
|
|
}
|
|
|
|
var mqSubmitCmd = &cobra.Command{
|
|
Use: "submit",
|
|
Short: "Submit current branch to the merge queue",
|
|
Long: `Submit the current branch to the merge queue.
|
|
|
|
Creates a merge-request bead that will be processed by the Refinery.
|
|
|
|
Auto-detection:
|
|
- Branch: current git branch
|
|
- Issue: parsed from branch name (e.g., polecat/Nux/gp-xyz → gt-xyz)
|
|
- Worker: parsed from branch name
|
|
- Rig: detected from current directory
|
|
- Target: automatically determined (see below)
|
|
- Priority: inherited from source issue
|
|
|
|
Target branch auto-detection:
|
|
1. If --epic is specified: target integration/<epic>
|
|
2. If source issue has a parent epic with integration/<epic> branch: target it
|
|
3. Otherwise: target main
|
|
|
|
This ensures batch work on epics automatically flows to integration branches.
|
|
|
|
Polecat auto-cleanup:
|
|
When run from a polecat work branch (polecat/<worker>/<issue>), this command
|
|
automatically triggers polecat shutdown after submitting the MR. The polecat
|
|
sends a lifecycle request to its Witness and waits for termination.
|
|
|
|
Use --no-cleanup to disable this behavior (e.g., if you want to submit
|
|
multiple MRs or continue working).
|
|
|
|
Examples:
|
|
gt mq submit # Auto-detect everything + auto-cleanup
|
|
gt mq submit --issue gp-abc # Explicit issue
|
|
gt mq submit --epic gt-xyz # Target integration branch explicitly
|
|
gt mq submit --priority 0 # Override priority (P0)
|
|
gt mq submit --no-cleanup # Submit without auto-cleanup`,
|
|
RunE: runMqSubmit,
|
|
}
|
|
|
|
var mqRetryCmd = &cobra.Command{
|
|
Use: "retry <rig> <mr-id>",
|
|
Short: "Retry a failed merge request",
|
|
Long: `Retry a failed merge request.
|
|
|
|
Resets a failed MR so it can be processed again by the refinery.
|
|
The MR must be in a failed state (open with an error).
|
|
|
|
Examples:
|
|
gt mq retry greenplace gp-mr-abc123
|
|
gt mq retry greenplace gp-mr-abc123 --now`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: runMQRetry,
|
|
}
|
|
|
|
var mqListCmd = &cobra.Command{
|
|
Use: "list <rig>",
|
|
Short: "Show the merge queue",
|
|
Long: `Show the merge queue for a rig.
|
|
|
|
Lists all pending merge requests waiting to be processed.
|
|
|
|
Output format:
|
|
ID STATUS PRIORITY BRANCH WORKER AGE
|
|
gt-mr-001 ready P0 polecat/Nux/gp-xyz Nux 5m
|
|
gt-mr-002 in_progress P1 polecat/Toast/gt-abc Toast 12m
|
|
gt-mr-003 blocked P1 polecat/Capable/gt-def Capable 8m
|
|
(waiting on gt-mr-001)
|
|
|
|
Examples:
|
|
gt mq list greenplace
|
|
gt mq list greenplace --ready
|
|
gt mq list greenplace --status=open
|
|
gt mq list greenplace --worker=Nux`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runMQList,
|
|
}
|
|
|
|
var mqRejectCmd = &cobra.Command{
|
|
Use: "reject <rig> <mr-id-or-branch>",
|
|
Short: "Reject a merge request",
|
|
Long: `Manually reject a merge request.
|
|
|
|
This closes the MR with a 'rejected' status without merging.
|
|
The source issue is NOT closed (work is not done).
|
|
|
|
Examples:
|
|
gt mq reject greenplace polecat/Nux/gp-xyz --reason "Does not meet requirements"
|
|
gt mq reject greenplace mr-Nux-12345 --reason "Superseded by other work" --notify`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: runMQReject,
|
|
}
|
|
|
|
var mqStatusCmd = &cobra.Command{
|
|
Use: "status <id>",
|
|
Short: "Show detailed merge request status",
|
|
Long: `Display detailed information about a merge request.
|
|
|
|
Shows all MR fields, current status with timestamps, dependencies,
|
|
blockers, and processing history.
|
|
|
|
Example:
|
|
gt mq status gp-mr-abc123`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runMqStatus,
|
|
}
|
|
|
|
var mqIntegrationCmd = &cobra.Command{
|
|
Use: "integration",
|
|
Short: "Manage integration branches for epics",
|
|
RunE: requireSubcommand,
|
|
Long: `Manage integration branches for batch work on epics.
|
|
|
|
Integration branches allow multiple MRs for an epic to target a shared
|
|
branch instead of main. After all epic work is complete, the integration
|
|
branch is landed to main as a single atomic unit.
|
|
|
|
Commands:
|
|
create Create an integration branch for an epic
|
|
land Merge integration branch to main
|
|
status Show integration branch status`,
|
|
}
|
|
|
|
var mqIntegrationCreateCmd = &cobra.Command{
|
|
Use: "create <epic-id>",
|
|
Short: "Create an integration branch for an epic",
|
|
Long: `Create an integration branch for batch work on an epic.
|
|
|
|
Creates a branch named integration/<epic-id> from main and pushes it
|
|
to origin. Future MRs for this epic's children can target this branch.
|
|
|
|
Actions:
|
|
1. Verify epic exists
|
|
2. Create branch integration/<epic-id> from main
|
|
3. Push to origin
|
|
4. Store integration branch info in epic metadata
|
|
|
|
Example:
|
|
gt mq integration create gt-auth-epic
|
|
# Creates integration/gt-auth-epic from main`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runMqIntegrationCreate,
|
|
}
|
|
|
|
var mqIntegrationLandCmd = &cobra.Command{
|
|
Use: "land <epic-id>",
|
|
Short: "Merge integration branch to main",
|
|
Long: `Merge an epic's integration branch to main.
|
|
|
|
Lands all work for an epic by merging its integration branch to main
|
|
as a single atomic merge commit.
|
|
|
|
Actions:
|
|
1. Verify all MRs targeting integration/<epic> are merged
|
|
2. Verify integration branch exists
|
|
3. Merge integration/<epic> to main (--no-ff)
|
|
4. Run tests on main
|
|
5. Push to origin
|
|
6. Delete integration branch
|
|
7. Update epic status
|
|
|
|
Options:
|
|
--force Land even if some MRs still open
|
|
--skip-tests Skip test run
|
|
--dry-run Preview only, make no changes
|
|
|
|
Examples:
|
|
gt mq integration land gt-auth-epic
|
|
gt mq integration land gt-auth-epic --dry-run
|
|
gt mq integration land gt-auth-epic --force --skip-tests`,
|
|
Args: cobra.ExactArgs(1),
|
|
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() {
|
|
// Submit flags
|
|
mqSubmitCmd.Flags().StringVar(&mqSubmitBranch, "branch", "", "Source branch (default: current branch)")
|
|
mqSubmitCmd.Flags().StringVar(&mqSubmitIssue, "issue", "", "Source issue ID (default: parse from branch name)")
|
|
mqSubmitCmd.Flags().StringVar(&mqSubmitEpic, "epic", "", "Target epic's integration branch instead of main")
|
|
mqSubmitCmd.Flags().IntVarP(&mqSubmitPriority, "priority", "p", -1, "Override priority (0-4, default: inherit from issue)")
|
|
mqSubmitCmd.Flags().BoolVar(&mqSubmitNoCleanup, "no-cleanup", false, "Don't auto-cleanup after submit (for polecats)")
|
|
|
|
// Retry flags
|
|
mqRetryCmd.Flags().BoolVar(&mqRetryNow, "now", false, "Immediately process instead of waiting for refinery loop")
|
|
|
|
// List flags
|
|
mqListCmd.Flags().BoolVar(&mqListReady, "ready", false, "Show only ready-to-merge (no blockers)")
|
|
mqListCmd.Flags().StringVar(&mqListStatus, "status", "", "Filter by status (open, in_progress, closed)")
|
|
mqListCmd.Flags().StringVar(&mqListWorker, "worker", "", "Filter by worker name")
|
|
mqListCmd.Flags().StringVar(&mqListEpic, "epic", "", "Show MRs targeting integration/<epic>")
|
|
mqListCmd.Flags().BoolVar(&mqListJSON, "json", false, "Output as JSON")
|
|
|
|
// Reject flags
|
|
mqRejectCmd.Flags().StringVarP(&mqRejectReason, "reason", "r", "", "Reason for rejection (required)")
|
|
mqRejectCmd.Flags().BoolVar(&mqRejectNotify, "notify", false, "Send mail notification to worker")
|
|
_ = mqRejectCmd.MarkFlagRequired("reason") // cobra flags: error only at runtime if missing
|
|
|
|
// Status flags
|
|
mqStatusCmd.Flags().BoolVar(&mqStatusJSON, "json", false, "Output as JSON")
|
|
|
|
// Add subcommands
|
|
mqCmd.AddCommand(mqSubmitCmd)
|
|
mqCmd.AddCommand(mqRetryCmd)
|
|
mqCmd.AddCommand(mqListCmd)
|
|
mqCmd.AddCommand(mqRejectCmd)
|
|
mqCmd.AddCommand(mqStatusCmd)
|
|
|
|
// Integration branch subcommands
|
|
mqIntegrationCmd.AddCommand(mqIntegrationCreateCmd)
|
|
|
|
// 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")
|
|
mqIntegrationCmd.AddCommand(mqIntegrationLandCmd)
|
|
|
|
// Integration status flags
|
|
mqIntegrationStatusCmd.Flags().BoolVar(&mqIntegrationStatusJSON, "json", false, "Output as JSON")
|
|
mqIntegrationCmd.AddCommand(mqIntegrationStatusCmd)
|
|
|
|
mqCmd.AddCommand(mqIntegrationCmd)
|
|
|
|
rootCmd.AddCommand(mqCmd)
|
|
}
|
|
|
|
// 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) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("getting current directory: %w", err)
|
|
}
|
|
|
|
// Get relative path from town root to cwd
|
|
relPath, err := filepath.Rel(townRoot, cwd)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("computing relative path: %w", err)
|
|
}
|
|
|
|
// The first component of the relative path should be the rig name
|
|
parts := strings.Split(relPath, string(filepath.Separator))
|
|
if len(parts) == 0 || parts[0] == "" || parts[0] == "." {
|
|
return "", nil, fmt.Errorf("not inside a rig directory")
|
|
}
|
|
|
|
rigName := parts[0]
|
|
|
|
// Load rig manager and get the rig
|
|
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
|
if err != nil {
|
|
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
|
}
|
|
|
|
g := git.NewGit(townRoot)
|
|
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
|
|
r, err := rigMgr.GetRig(rigName)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("rig '%s' not found: %w", rigName, err)
|
|
}
|
|
|
|
return rigName, r, nil
|
|
}
|
|
|
|
func runMQRetry(cmd *cobra.Command, args []string) error {
|
|
rigName := args[0]
|
|
mrID := args[1]
|
|
|
|
mgr, _, _, err := getRefineryManager(rigName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the MR first to show info
|
|
mr, err := mgr.GetMR(mrID)
|
|
if err != nil {
|
|
if err == refinery.ErrMRNotFound {
|
|
return fmt.Errorf("merge request '%s' not found in rig '%s'", mrID, rigName)
|
|
}
|
|
return fmt.Errorf("getting merge request: %w", err)
|
|
}
|
|
|
|
// Show what we're retrying
|
|
fmt.Printf("Retrying merge request: %s\n", mrID)
|
|
fmt.Printf(" Branch: %s\n", mr.Branch)
|
|
fmt.Printf(" Worker: %s\n", mr.Worker)
|
|
if mr.Error != "" {
|
|
fmt.Printf(" Previous error: %s\n", style.Dim.Render(mr.Error))
|
|
}
|
|
|
|
// Perform the retry
|
|
if err := mgr.Retry(mrID, mqRetryNow); err != nil {
|
|
if err == refinery.ErrMRNotFailed {
|
|
return fmt.Errorf("merge request '%s' has not failed (status: %s)", mrID, mr.Status)
|
|
}
|
|
return fmt.Errorf("retrying merge request: %w", err)
|
|
}
|
|
|
|
if mqRetryNow {
|
|
fmt.Printf("%s Merge request processed\n", style.Bold.Render("✓"))
|
|
} else {
|
|
fmt.Printf("%s Merge request queued for retry\n", style.Bold.Render("✓"))
|
|
fmt.Printf(" %s\n", style.Dim.Render("Will be processed on next refinery cycle"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runMQReject(cmd *cobra.Command, args []string) error {
|
|
rigName := args[0]
|
|
mrIDOrBranch := args[1]
|
|
|
|
mgr, _, _, err := getRefineryManager(rigName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result, err := mgr.RejectMR(mrIDOrBranch, mqRejectReason, mqRejectNotify)
|
|
if err != nil {
|
|
return fmt.Errorf("rejecting MR: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Rejected: %s\n", style.Bold.Render("✗"), result.Branch)
|
|
fmt.Printf(" Worker: %s\n", result.Worker)
|
|
fmt.Printf(" Reason: %s\n", mqRejectReason)
|
|
|
|
if result.IssueID != "" {
|
|
fmt.Printf(" Issue: %s %s\n", result.IssueID, style.Dim.Render("(not closed - work not done)"))
|
|
}
|
|
|
|
if mqRejectNotify {
|
|
fmt.Printf(" %s\n", style.Dim.Render("Worker notified via mail"))
|
|
}
|
|
|
|
return nil
|
|
}
|