Files
gastown/internal/cmd/mq_integration.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

754 lines
23 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"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/style"
"github.com/steveyegge/gastown/internal/workspace"
)
// Integration branch template constants
const defaultIntegrationBranchTemplate = "integration/{epic}"
// invalidBranchCharsRegex matches characters that are invalid in git branch names.
// Git branch names cannot contain: ~ ^ : \ space, .., @{, or end with .lock
var invalidBranchCharsRegex = regexp.MustCompile(`[~^:\s\\]|\.\.|\.\.|@\{`)
// buildIntegrationBranchName expands an integration branch template with variables.
// Variables supported:
// - {epic}: Full epic ID (e.g., "RA-123")
// - {prefix}: Epic prefix before first hyphen (e.g., "RA")
// - {user}: Git user.name (e.g., "klauern")
//
// If template is empty, uses defaultIntegrationBranchTemplate.
func buildIntegrationBranchName(template, epicID string) string {
if template == "" {
template = defaultIntegrationBranchTemplate
}
result := template
result = strings.ReplaceAll(result, "{epic}", epicID)
result = strings.ReplaceAll(result, "{prefix}", extractEpicPrefix(epicID))
// Git user (optional - leaves placeholder if not available)
if user := getGitUserName(); user != "" {
result = strings.ReplaceAll(result, "{user}", user)
}
return result
}
// extractEpicPrefix extracts the prefix from an epic ID (before the first hyphen).
// Examples: "RA-123" -> "RA", "PROJ-456" -> "PROJ", "abc" -> "abc"
func extractEpicPrefix(epicID string) string {
if idx := strings.Index(epicID, "-"); idx > 0 {
return epicID[:idx]
}
return epicID
}
// getGitUserName returns the git user.name config value, or empty if not set.
func getGitUserName() string {
cmd := exec.Command("git", "config", "user.name")
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// validateBranchName checks if a branch name is valid for git.
// Returns an error if the branch name contains invalid characters.
func validateBranchName(branchName string) error {
if branchName == "" {
return fmt.Errorf("branch name cannot be empty")
}
// Check for invalid characters
if invalidBranchCharsRegex.MatchString(branchName) {
return fmt.Errorf("branch name %q contains invalid characters (~ ^ : \\ space, .., or @{)", branchName)
}
// Check for .lock suffix
if strings.HasSuffix(branchName, ".lock") {
return fmt.Errorf("branch name %q cannot end with .lock", branchName)
}
// Check for leading/trailing slashes or dots
if strings.HasPrefix(branchName, "/") || strings.HasSuffix(branchName, "/") {
return fmt.Errorf("branch name %q cannot start or end with /", branchName)
}
if strings.HasPrefix(branchName, ".") || strings.HasSuffix(branchName, ".") {
return fmt.Errorf("branch name %q cannot start or end with .", branchName)
}
// Check for consecutive slashes
if strings.Contains(branchName, "//") {
return fmt.Errorf("branch name %q cannot contain consecutive slashes", branchName)
}
return nil
}
// getIntegrationBranchField extracts the integration_branch field from an epic's description.
// Returns empty string if the field is not found.
func getIntegrationBranchField(description string) string {
if description == "" {
return ""
}
lines := strings.Split(description, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(trimmed), "integration_branch:") {
value := strings.TrimPrefix(trimmed, "integration_branch:")
value = strings.TrimPrefix(value, "Integration_branch:")
value = strings.TrimPrefix(value, "INTEGRATION_BRANCH:")
// Handle case variations
for _, prefix := range []string{"integration_branch:", "Integration_branch:", "INTEGRATION_BRANCH:"} {
if strings.HasPrefix(trimmed, prefix) {
value = strings.TrimPrefix(trimmed, prefix)
break
}
}
// Re-parse properly - the prefix removal above is messy
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return ""
}
// getIntegrationBranchTemplate returns the integration branch template to use.
// Priority: CLI flag > rig config > default
func getIntegrationBranchTemplate(rigPath, cliOverride string) string {
if cliOverride != "" {
return cliOverride
}
// Try to load rig settings
settingsPath := filepath.Join(rigPath, "settings", "config.json")
settings, err := config.LoadRigSettings(settingsPath)
if err != nil {
return defaultIntegrationBranchTemplate
}
if settings.MergeQueue != nil && settings.MergeQueue.IntegrationBranchTemplate != "" {
return settings.MergeQueue.IntegrationBranchTemplate
}
return defaultIntegrationBranchTemplate
}
// 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 from template
template := getIntegrationBranchTemplate(r.Path, mqIntegrationCreateBranch)
branchName := buildIntegrationBranchName(template, epicID)
// Validate the branch name
if err := validateBranchName(branchName); err != nil {
return fmt.Errorf("invalid branch name: %w", err)
}
// 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 (best-effort cleanup)
_ = 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)
// 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)
}
// Get integration branch name from epic metadata (stored at create time)
// Fall back to default template for backward compatibility with old epics
branchName := getIntegrationBranchField(epic.Description)
if branchName == "" {
branchName = buildIntegrationBranchName(defaultIntegrationBranchTemplate, epicID)
}
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 (best-effort cleanup)
_ = 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") // best-effort: need to be on main to reset
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 settings.
func getTestCommand(rigPath string) string {
settingsPath := filepath.Join(rigPath, "settings", "config.json")
settings, err := config.LoadRigSettings(settingsPath)
if err != nil {
return ""
}
if settings.MergeQueue != nil && settings.MergeQueue.TestCommand != "" {
return settings.MergeQueue.TestCommand
}
return ""
}
// 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)
// Fetch epic to get stored branch name
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)
}
// Get integration branch name from epic metadata (stored at create time)
// Fall back to default template for backward compatibility with old epics
branchName := getIntegrationBranchField(epic.Description)
if branchName == "" {
branchName = buildIntegrationBranchName(defaultIntegrationBranchTemplate, 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 (use resolved name)
targetBranch := branchName
// 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
}