Files
gastown/internal/cmd/mq.go
gastown/crew/joe 358fcaf935 feat(mq): add configurable integration branch naming (#104)
Enterprise teams can now customize integration branch names to match
their conventions (e.g., username/TICKET-123/feature-name).

- Add integration_branch_template to MergeQueueConfig
- Add --branch CLI override for gt mq integration create
- Support {epic}, {prefix}, {user} template variables
- Validate branch names for git-safe characters
- Store actual branch name in epic metadata at create time
- Read stored branch name in land/status (fallback for old epics)

Also fixes unrelated build error in polecat/manager.go (polecatPath
variable was undefined).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 00:41:35 -08:00

435 lines
14 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
// Integration create flags
mqIntegrationCreateBranch string
)
var mqCmd = &cobra.Command{
Use: "mq",
Aliases: []string{"mr"},
GroupID: GroupWork,
Short: "Merge queue operations",
RunE: requireSubcommand,
Long: `Manage merge requests and the merge queue for a rig.
Alias: 'gt mr' is equivalent to 'gt mq' (merge request vs merge queue).
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 from main and pushes it to origin. Future MRs for this
epic's children can target this branch.
Branch naming:
Default: integration/<epic-id>
Config: Set merge_queue.integration_branch_template in rig settings
Override: Use --branch flag for one-off customization
Template variables:
{epic} - Full epic ID (e.g., "RA-123")
{prefix} - Epic prefix before first hyphen (e.g., "RA")
{user} - Git user.name (e.g., "klauern")
Actions:
1. Verify epic exists
2. Create branch from main (using template or --branch)
3. Push to origin
4. Store actual branch name in epic metadata
Examples:
gt mq integration create gt-auth-epic
# Creates integration/gt-auth-epic (default)
gt mq integration create RA-123 --branch "klauern/PROJ-1234/{epic}"
# Creates klauern/PROJ-1234/RA-123`,
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
mqIntegrationCreateCmd.Flags().StringVar(&mqIntegrationCreateBranch, "branch", "", "Override branch name template (supports {epic}, {prefix}, {user})")
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
}