From 81c5f6afd503f1bf5833c7d2213176a30f4df994 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 20:11:09 -0800 Subject: [PATCH] feat: implement gt mq retry command for failed merge requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'gt mq retry ' command to retry failed MRs: - Added GetMR() and Retry() methods to refinery.Manager - Added RegisterMR() for persistent MR tracking - Added PendingMRs field to Refinery state - Created new mq.go command file with retry subcommand - Support --now flag for immediate processing - Added comprehensive tests for retry functionality Closes gt-svi.4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mq.go | 92 ++++++++++++++++ internal/refinery/manager.go | 84 +++++++++++++++ internal/refinery/manager_test.go | 172 ++++++++++++++++++++++++++++++ internal/refinery/types.go | 4 + 4 files changed, 352 insertions(+) create mode 100644 internal/cmd/mq.go create mode 100644 internal/refinery/manager_test.go diff --git a/internal/cmd/mq.go b/internal/cmd/mq.go new file mode 100644 index 00000000..82ded823 --- /dev/null +++ b/internal/cmd/mq.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/refinery" + "github.com/steveyegge/gastown/internal/style" +) + +// mq command flags +var ( + mqRetryNow bool +) + +var mqCmd = &cobra.Command{ + Use: "mq", + Short: "Merge queue operations", + Long: `Manage the merge queue for a rig. + +The merge queue tracks work from polecats waiting to be merged. +Use these commands to view, retry, and manage merge requests.`, +} + +var mqRetryCmd = &cobra.Command{ + Use: "retry ", + 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 gastown gt-mr-abc123 + gt mq retry gastown gt-mr-abc123 --now`, + Args: cobra.ExactArgs(2), + RunE: runMQRetry, +} + +func init() { + // Retry flags + mqRetryCmd.Flags().BoolVar(&mqRetryNow, "now", false, "Immediately process instead of waiting for refinery loop") + + // Add subcommands + mqCmd.AddCommand(mqRetryCmd) + + rootCmd.AddCommand(mqCmd) +} + +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 +} diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index c8ad3971..9e8c2d0e 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -551,6 +551,90 @@ Thank you for your contribution!`, router.Send(msg) } +// ErrMRNotFound is returned when a merge request is not found. +var ErrMRNotFound = errors.New("merge request not found") + +// ErrMRNotFailed is returned when trying to retry an MR that hasn't failed. +var ErrMRNotFailed = errors.New("merge request has not failed") + +// GetMR returns a merge request by ID. +func (m *Manager) GetMR(id string) (*MergeRequest, error) { + ref, err := m.loadState() + if err != nil { + return nil, err + } + + // Check if it's the current MR + if ref.CurrentMR != nil && ref.CurrentMR.ID == id { + return ref.CurrentMR, nil + } + + // Check pending MRs + if ref.PendingMRs != nil { + if mr, ok := ref.PendingMRs[id]; ok { + return mr, nil + } + } + + return nil, ErrMRNotFound +} + +// Retry resets a failed merge request so it can be processed again. +// If processNow is true, immediately processes the MR instead of waiting for the loop. +func (m *Manager) Retry(id string, processNow bool) error { + ref, err := m.loadState() + if err != nil { + return err + } + + // Find the MR + var mr *MergeRequest + if ref.PendingMRs != nil { + mr = ref.PendingMRs[id] + } + if mr == nil { + return ErrMRNotFound + } + + // Verify it's in a failed state (open with an error) + if mr.Status != MROpen || mr.Error == "" { + return ErrMRNotFailed + } + + // Clear the error to mark as ready for retry + mr.Error = "" + + // Save the state + if err := m.saveState(ref); err != nil { + return err + } + + // If --now flag, process immediately + if processNow { + result := m.ProcessMR(mr) + if !result.Success { + return fmt.Errorf("retry failed: %s", result.Error) + } + } + + return nil +} + +// RegisterMR adds a merge request to the pending queue. +func (m *Manager) RegisterMR(mr *MergeRequest) error { + ref, err := m.loadState() + if err != nil { + return err + } + + if ref.PendingMRs == nil { + ref.PendingMRs = make(map[string]*MergeRequest) + } + + ref.PendingMRs[mr.ID] = mr + return m.saveState(ref) +} + // findTownRoot walks up directories to find the town root. func findTownRoot(startPath string) string { path := startPath diff --git a/internal/refinery/manager_test.go b/internal/refinery/manager_test.go new file mode 100644 index 00000000..19dc20d2 --- /dev/null +++ b/internal/refinery/manager_test.go @@ -0,0 +1,172 @@ +package refinery + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/steveyegge/gastown/internal/rig" +) + +func setupTestManager(t *testing.T) (*Manager, string) { + t.Helper() + + // Create temp directory structure + tmpDir := t.TempDir() + rigPath := filepath.Join(tmpDir, "testrig") + if err := os.MkdirAll(filepath.Join(rigPath, ".gastown"), 0755); err != nil { + t.Fatalf("mkdir .gastown: %v", err) + } + + r := &rig.Rig{ + Name: "testrig", + Path: rigPath, + } + + return NewManager(r), rigPath +} + +func TestManager_GetMR(t *testing.T) { + mgr, _ := setupTestManager(t) + + // Create a test MR in the pending queue + mr := &MergeRequest{ + ID: "gt-mr-abc123", + Branch: "polecat/Toast/gt-xyz", + Worker: "Toast", + IssueID: "gt-xyz", + Status: MROpen, + Error: "test failure", + } + + if err := mgr.RegisterMR(mr); err != nil { + t.Fatalf("RegisterMR: %v", err) + } + + t.Run("find existing MR", func(t *testing.T) { + found, err := mgr.GetMR("gt-mr-abc123") + if err != nil { + t.Errorf("GetMR() unexpected error: %v", err) + } + if found == nil { + t.Fatal("GetMR() returned nil") + } + if found.ID != mr.ID { + t.Errorf("GetMR() ID = %s, want %s", found.ID, mr.ID) + } + }) + + t.Run("MR not found", func(t *testing.T) { + _, err := mgr.GetMR("nonexistent-mr") + if err != ErrMRNotFound { + t.Errorf("GetMR() error = %v, want %v", err, ErrMRNotFound) + } + }) +} + +func TestManager_Retry(t *testing.T) { + t.Run("retry failed MR clears error", func(t *testing.T) { + mgr, _ := setupTestManager(t) + + // Create a failed MR + mr := &MergeRequest{ + ID: "gt-mr-failed", + Branch: "polecat/Toast/gt-xyz", + Worker: "Toast", + Status: MROpen, + Error: "merge conflict", + } + + if err := mgr.RegisterMR(mr); err != nil { + t.Fatalf("RegisterMR: %v", err) + } + + // Retry without processing + err := mgr.Retry("gt-mr-failed", false) + if err != nil { + t.Errorf("Retry() unexpected error: %v", err) + } + + // Verify error was cleared + found, _ := mgr.GetMR("gt-mr-failed") + if found.Error != "" { + t.Errorf("Retry() error not cleared, got %s", found.Error) + } + }) + + t.Run("retry non-failed MR fails", func(t *testing.T) { + mgr, _ := setupTestManager(t) + + // Create a successful MR (no error) + mr := &MergeRequest{ + ID: "gt-mr-success", + Branch: "polecat/Toast/gt-abc", + Worker: "Toast", + Status: MROpen, + Error: "", // No error + } + + if err := mgr.RegisterMR(mr); err != nil { + t.Fatalf("RegisterMR: %v", err) + } + + err := mgr.Retry("gt-mr-success", false) + if err != ErrMRNotFailed { + t.Errorf("Retry() error = %v, want %v", err, ErrMRNotFailed) + } + }) + + t.Run("retry nonexistent MR fails", func(t *testing.T) { + mgr, _ := setupTestManager(t) + + err := mgr.Retry("nonexistent", false) + if err != ErrMRNotFound { + t.Errorf("Retry() error = %v, want %v", err, ErrMRNotFound) + } + }) +} + +func TestManager_RegisterMR(t *testing.T) { + mgr, rigPath := setupTestManager(t) + + mr := &MergeRequest{ + ID: "gt-mr-new", + Branch: "polecat/Cheedo/gt-123", + Worker: "Cheedo", + IssueID: "gt-123", + TargetBranch: "main", + CreatedAt: time.Now(), + Status: MROpen, + } + + if err := mgr.RegisterMR(mr); err != nil { + t.Fatalf("RegisterMR: %v", err) + } + + // Verify it was saved to disk + stateFile := filepath.Join(rigPath, ".gastown", "refinery.json") + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatalf("reading state file: %v", err) + } + + var ref Refinery + if err := json.Unmarshal(data, &ref); err != nil { + t.Fatalf("unmarshal state: %v", err) + } + + if ref.PendingMRs == nil { + t.Fatal("PendingMRs is nil") + } + + saved, ok := ref.PendingMRs["gt-mr-new"] + if !ok { + t.Fatal("MR not found in PendingMRs") + } + + if saved.Worker != "Cheedo" { + t.Errorf("saved MR worker = %s, want Cheedo", saved.Worker) + } +} diff --git a/internal/refinery/types.go b/internal/refinery/types.go index 4aa86c87..6d220a00 100644 --- a/internal/refinery/types.go +++ b/internal/refinery/types.go @@ -38,6 +38,10 @@ type Refinery struct { // CurrentMR is the merge request currently being processed. CurrentMR *MergeRequest `json:"current_mr,omitempty"` + // PendingMRs tracks merge requests that have been submitted. + // Key is the MR ID. + PendingMRs map[string]*MergeRequest `json:"pending_mrs,omitempty"` + // LastMergeAt is when the last successful merge happened. LastMergeAt *time.Time `json:"last_merge_at,omitempty"`