Skip to content

Commit f71cb7c

Browse files
author
rictus
committed
feat(mq): add process command for manual MR processing (gt-lzf3.1)
Add 'gt mq process <rig> [mr-id]' command that allows manual processing of merge requests from the merge queue. Features: - Process next highest-priority MR: gt mq process gastown - Process specific MR by ID: gt mq process gastown gt-mr-abc - Preview mode with --dry-run flag - JSON output with --json flag The command performs the full merge workflow: 1. Claims the MR to prevent concurrent processing 2. Merges source branch into target (typically main) 3. Runs tests if configured 4. Pushes merged result 5. Closes MR bead and source issue 6. Deletes merged branch if configured This streamlines the refinery workflow by allowing CLI-based merge processing without requiring the full refinery agent.
1 parent 278b2f2 commit f71cb7c

1 file changed

Lines changed: 312 additions & 0 deletions

File tree

internal/cmd/mq_process.go

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sort"
7+
"time"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/steveyegge/gastown/internal/beads"
11+
"github.com/steveyegge/gastown/internal/refinery"
12+
"github.com/steveyegge/gastown/internal/style"
13+
)
14+
15+
// MQ process command flags
16+
var (
17+
mqProcessDryRun bool
18+
mqProcessJSON bool
19+
)
20+
21+
var mqProcessCmd = &cobra.Command{
22+
Use: "process <rig> [mr-id]",
23+
Short: "Process the next merge request (or a specific one)",
24+
Long: `Process merge requests from the queue.
25+
26+
If no MR ID is provided, processes the highest-priority ready MR.
27+
If an MR ID is provided, processes that specific MR.
28+
29+
This command performs the full merge workflow:
30+
1. Claims the MR (prevents concurrent processing)
31+
2. Fetches and checks out the source branch
32+
3. Merges into the target branch (typically main)
33+
4. Runs tests if configured
34+
5. Pushes the merged result
35+
6. Closes the MR bead and source issue
36+
7. Deletes the merged branch if configured
37+
38+
Use --dry-run to preview what would be processed without making changes.
39+
40+
Examples:
41+
gt mq process gastown # Process next ready MR
42+
gt mq process gastown gt-mr-abc123 # Process specific MR
43+
gt mq process gastown --dry-run # Preview next MR without processing`,
44+
Args: cobra.RangeArgs(1, 2),
45+
RunE: runMqProcess,
46+
}
47+
48+
func init() {
49+
mqProcessCmd.Flags().BoolVar(&mqProcessDryRun, "dry-run", false, "Preview what would be processed without making changes")
50+
mqProcessCmd.Flags().BoolVar(&mqProcessJSON, "json", false, "Output result as JSON")
51+
52+
mqCmd.AddCommand(mqProcessCmd)
53+
}
54+
55+
func runMqProcess(cmd *cobra.Command, args []string) error {
56+
rigName := args[0]
57+
var mrID string
58+
if len(args) > 1 {
59+
mrID = args[1]
60+
}
61+
62+
_, r, _, err := getRefineryManager(rigName)
63+
if err != nil {
64+
return err
65+
}
66+
67+
// Create engineer for the rig
68+
eng := refinery.NewEngineer(r)
69+
if err := eng.LoadConfig(); err != nil {
70+
return fmt.Errorf("loading merge queue config: %w", err)
71+
}
72+
73+
// Create beads wrapper for the rig
74+
b := beads.New(r.BeadsPath())
75+
76+
var mr *refinery.MRInfo
77+
78+
if mrID != "" {
79+
// Process specific MR
80+
mr, err = getMRByID(b, mrID)
81+
if err != nil {
82+
return err
83+
}
84+
} else {
85+
// Get the next ready MR
86+
mr, err = getNextReadyMR(b, r.DefaultBranch())
87+
if err != nil {
88+
return err
89+
}
90+
}
91+
92+
if mr == nil {
93+
if mqProcessJSON {
94+
fmt.Println(`{"status": "empty", "message": "No ready merge requests in queue"}`)
95+
} else {
96+
fmt.Printf("%s No ready merge requests in queue\n", style.Dim.Render("ℹ"))
97+
}
98+
return nil
99+
}
100+
101+
// Display what we're processing
102+
if !mqProcessJSON {
103+
fmt.Printf("%s Processing merge request:\n\n", style.Bold.Render("🔄"))
104+
fmt.Printf(" ID: %s\n", mr.ID)
105+
fmt.Printf(" Branch: %s\n", mr.Branch)
106+
fmt.Printf(" Target: %s\n", mr.Target)
107+
fmt.Printf(" Worker: %s\n", mr.Worker)
108+
fmt.Printf(" Priority: P%d\n", mr.Priority)
109+
if mr.SourceIssue != "" {
110+
fmt.Printf(" Issue: %s\n", mr.SourceIssue)
111+
}
112+
fmt.Println()
113+
}
114+
115+
if mqProcessDryRun {
116+
if mqProcessJSON {
117+
return outputJSON(map[string]interface{}{
118+
"status": "dry_run",
119+
"mr_id": mr.ID,
120+
"branch": mr.Branch,
121+
"target": mr.Target,
122+
"worker": mr.Worker,
123+
"message": "Would process this MR (dry-run mode)",
124+
})
125+
}
126+
fmt.Printf("%s Dry run - would process %s\n", style.Dim.Render("ℹ"), mr.ID)
127+
return nil
128+
}
129+
130+
// Claim the MR
131+
workerID := fmt.Sprintf("%s/refinery-cli", rigName)
132+
if err := eng.ClaimMR(mr.ID, workerID); err != nil {
133+
return fmt.Errorf("claiming MR: %w", err)
134+
}
135+
136+
// Process the MR
137+
ctx := context.Background()
138+
result := eng.ProcessMRInfo(ctx, mr)
139+
140+
// Handle result
141+
if result.Success {
142+
eng.HandleMRInfoSuccess(mr, result)
143+
144+
if mqProcessJSON {
145+
return outputJSON(map[string]interface{}{
146+
"status": "success",
147+
"mr_id": mr.ID,
148+
"merge_commit": result.MergeCommit,
149+
"message": fmt.Sprintf("Successfully merged %s", mr.Branch),
150+
})
151+
}
152+
153+
fmt.Printf("\n%s Successfully merged!\n", style.Bold.Render("✓"))
154+
fmt.Printf(" Commit: %s\n", result.MergeCommit)
155+
} else {
156+
eng.HandleMRInfoFailure(mr, result)
157+
158+
// Release the claim since we failed
159+
_ = eng.ReleaseMR(mr.ID)
160+
161+
if mqProcessJSON {
162+
return outputJSON(map[string]interface{}{
163+
"status": "failed",
164+
"mr_id": mr.ID,
165+
"error": result.Error,
166+
"conflict": result.Conflict,
167+
"tests_failed": result.TestsFailed,
168+
})
169+
}
170+
171+
errType := "Error"
172+
if result.Conflict {
173+
errType = "Conflict"
174+
} else if result.TestsFailed {
175+
errType = "Tests failed"
176+
}
177+
178+
fmt.Printf("\n%s %s: %s\n", style.Bold.Render("✗"), errType, result.Error)
179+
return fmt.Errorf("merge failed: %s", result.Error)
180+
}
181+
182+
return nil
183+
}
184+
185+
// getMRByID fetches a specific MR by ID from beads.
186+
func getMRByID(b *beads.Beads, mrID string) (*refinery.MRInfo, error) {
187+
issue, err := b.Show(mrID)
188+
if err != nil {
189+
return nil, fmt.Errorf("MR %s not found: %w", mrID, err)
190+
}
191+
192+
fields := beads.ParseMRFields(issue)
193+
if fields == nil {
194+
return nil, fmt.Errorf("MR %s has no merge request fields", mrID)
195+
}
196+
197+
// Parse times
198+
var convoyCreatedAt *time.Time
199+
if fields.ConvoyCreatedAt != "" {
200+
if t, err := time.Parse(time.RFC3339, fields.ConvoyCreatedAt); err == nil {
201+
convoyCreatedAt = &t
202+
}
203+
}
204+
205+
var createdAt time.Time
206+
if issue.CreatedAt != "" {
207+
if t, err := time.Parse(time.RFC3339, issue.CreatedAt); err == nil {
208+
createdAt = t
209+
}
210+
}
211+
212+
return &refinery.MRInfo{
213+
ID: issue.ID,
214+
Branch: fields.Branch,
215+
Target: fields.Target,
216+
SourceIssue: fields.SourceIssue,
217+
Worker: fields.Worker,
218+
Rig: fields.Rig,
219+
Title: issue.Title,
220+
Priority: issue.Priority,
221+
AgentBead: fields.AgentBead,
222+
RetryCount: fields.RetryCount,
223+
ConvoyID: fields.ConvoyID,
224+
ConvoyCreatedAt: convoyCreatedAt,
225+
CreatedAt: createdAt,
226+
}, nil
227+
}
228+
229+
// getNextReadyMR finds the highest-priority ready MR from the queue.
230+
func getNextReadyMR(b *beads.Beads, defaultBranch string) (*refinery.MRInfo, error) {
231+
// Query for open merge-requests
232+
opts := beads.ListOptions{
233+
Type: "merge-request",
234+
Status: "open",
235+
Priority: -1, // No priority filter
236+
}
237+
238+
issues, err := b.List(opts)
239+
if err != nil {
240+
return nil, fmt.Errorf("querying merge queue: %w", err)
241+
}
242+
243+
// Filter to ready MRs (no blockers, no assignee)
244+
var ready []*beads.Issue
245+
for _, issue := range issues {
246+
// Skip blocked issues
247+
if len(issue.BlockedBy) > 0 || issue.BlockedByCount > 0 {
248+
continue
249+
}
250+
// Skip already claimed issues
251+
if issue.Assignee != "" {
252+
continue
253+
}
254+
ready = append(ready, issue)
255+
}
256+
257+
if len(ready) == 0 {
258+
return nil, nil
259+
}
260+
261+
// Sort by priority score (highest first)
262+
now := time.Now()
263+
sort.Slice(ready, func(i, j int) bool {
264+
scoreI := calculateMRScore(ready[i], beads.ParseMRFields(ready[i]), now)
265+
scoreJ := calculateMRScore(ready[j], beads.ParseMRFields(ready[j]), now)
266+
return scoreI > scoreJ
267+
})
268+
269+
// Convert the top issue to MRInfo
270+
issue := ready[0]
271+
fields := beads.ParseMRFields(issue)
272+
if fields == nil {
273+
return nil, fmt.Errorf("top MR %s has no merge request fields", issue.ID)
274+
}
275+
276+
// Parse times
277+
var convoyCreatedAt *time.Time
278+
if fields.ConvoyCreatedAt != "" {
279+
if t, err := time.Parse(time.RFC3339, fields.ConvoyCreatedAt); err == nil {
280+
convoyCreatedAt = &t
281+
}
282+
}
283+
284+
var createdAt time.Time
285+
if issue.CreatedAt != "" {
286+
if t, err := time.Parse(time.RFC3339, issue.CreatedAt); err == nil {
287+
createdAt = t
288+
}
289+
}
290+
291+
// Default target to the rig's default branch if not specified
292+
target := fields.Target
293+
if target == "" {
294+
target = defaultBranch
295+
}
296+
297+
return &refinery.MRInfo{
298+
ID: issue.ID,
299+
Branch: fields.Branch,
300+
Target: target,
301+
SourceIssue: fields.SourceIssue,
302+
Worker: fields.Worker,
303+
Rig: fields.Rig,
304+
Title: issue.Title,
305+
Priority: issue.Priority,
306+
AgentBead: fields.AgentBead,
307+
RetryCount: fields.RetryCount,
308+
ConvoyID: fields.ConvoyID,
309+
ConvoyCreatedAt: convoyCreatedAt,
310+
CreatedAt: createdAt,
311+
}, nil
312+
}

0 commit comments

Comments
 (0)