Files
gastown/internal/refinery/manager.go
Steve Yegge 59d656470e Add Claude settings templates for autonomous roles (gt-6957)
- Create internal/claude package with embedded settings templates
- settings-autonomous.json: gt prime && gt mail check --inject (SessionStart)
- settings-interactive.json: gt prime only (SessionStart)

- Update witness.go: EnsureSettings before session, remove broken gt prime injection
- Update refinery/manager.go: EnsureSettings before session, remove broken NudgeSession
- Update session/manager.go: EnsureSettings for polecats, remove broken issue injection

All autonomous roles (polecat, witness, refinery) now get proper SessionStart hooks
automatically when their sessions are created. No more timing-based gt prime injection.
2025-12-22 17:51:15 -08:00

930 lines
24 KiB
Go

package refinery
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/tmux"
)
// Common errors
var (
ErrNotRunning = errors.New("refinery not running")
ErrAlreadyRunning = errors.New("refinery already running")
ErrNoQueue = errors.New("no items in queue")
)
// Manager handles refinery lifecycle and queue operations.
type Manager struct {
rig *rig.Rig
workDir string
output io.Writer // Output destination for user-facing messages
}
// NewManager creates a new refinery manager for a rig.
func NewManager(r *rig.Rig) *Manager {
return &Manager{
rig: r,
workDir: r.Path,
output: os.Stdout,
}
}
// SetOutput sets the output writer for user-facing messages.
// This is useful for testing or redirecting output.
func (m *Manager) SetOutput(w io.Writer) {
m.output = w
}
// stateFile returns the path to the refinery state file.
func (m *Manager) stateFile() string {
return filepath.Join(m.rig.Path, ".runtime", "refinery.json")
}
// sessionName returns the tmux session name for this refinery.
func (m *Manager) sessionName() string {
return fmt.Sprintf("gt-%s-refinery", m.rig.Name)
}
// loadState loads refinery state from disk.
func (m *Manager) loadState() (*Refinery, error) {
data, err := os.ReadFile(m.stateFile())
if err != nil {
if os.IsNotExist(err) {
return &Refinery{
RigName: m.rig.Name,
State: StateStopped,
}, nil
}
return nil, err
}
var ref Refinery
if err := json.Unmarshal(data, &ref); err != nil {
return nil, err
}
return &ref, nil
}
// saveState persists refinery state to disk.
func (m *Manager) saveState(ref *Refinery) error {
dir := filepath.Dir(m.stateFile())
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(ref, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.stateFile(), data, 0644)
}
// Status returns the current refinery status.
func (m *Manager) Status() (*Refinery, error) {
ref, err := m.loadState()
if err != nil {
return nil, err
}
// Check if tmux session exists
t := tmux.NewTmux()
sessionID := m.sessionName()
sessionRunning, _ := t.HasSession(sessionID)
// If tmux session is running, refinery is running
if sessionRunning {
if ref.State != StateRunning {
// Update state to match reality
now := time.Now()
ref.State = StateRunning
if ref.StartedAt == nil {
ref.StartedAt = &now
}
_ = m.saveState(ref)
}
return ref, nil
}
// If state says running but tmux session doesn't exist, check PID
if ref.State == StateRunning {
if ref.PID > 0 && processExists(ref.PID) {
// Process is still running (foreground mode without tmux)
return ref, nil
}
// Neither session nor process exists - mark as stopped
ref.State = StateStopped
ref.PID = 0
_ = m.saveState(ref)
}
return ref, nil
}
// Start starts the refinery.
// If foreground is true, runs in the current process (blocking) using the Go-based polling loop.
// Otherwise, spawns a Claude agent in a tmux session to process the merge queue.
func (m *Manager) Start(foreground bool) error {
ref, err := m.loadState()
if err != nil {
return err
}
t := tmux.NewTmux()
sessionID := m.sessionName()
if foreground {
// In foreground mode, we're likely running inside the tmux session
// that background mode created. Only check PID to avoid self-detection.
if ref.State == StateRunning && ref.PID > 0 && processExists(ref.PID) {
return ErrAlreadyRunning
}
// Running in foreground - update state and run the Go-based polling loop
now := time.Now()
ref.State = StateRunning
ref.StartedAt = &now
ref.PID = os.Getpid()
if err := m.saveState(ref); err != nil {
return err
}
// Run the processing loop (blocking)
return m.run(ref)
}
// Background mode: check if session already exists
running, _ := t.HasSession(sessionID)
if running {
return ErrAlreadyRunning
}
// Also check via PID for backwards compatibility
if ref.State == StateRunning && ref.PID > 0 && processExists(ref.PID) {
return ErrAlreadyRunning
}
// Background mode: spawn a Claude agent in a tmux session
// The Claude agent handles MR processing using git commands and beads
// Working directory is the refinery's rig clone (canonical main branch view)
refineryRigDir := filepath.Join(m.rig.Path, "refinery", "rig")
if _, err := os.Stat(refineryRigDir); os.IsNotExist(err) {
// Fall back to rig path if refinery/rig doesn't exist
refineryRigDir = m.workDir
}
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
if err := claude.EnsureSettingsForRole(refineryRigDir, "refinery"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
}
if err := t.NewSession(sessionID, refineryRigDir); err != nil {
return fmt.Errorf("creating tmux session: %w", err)
}
// Set environment variables
_ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)
_ = t.SetEnvironment(sessionID, "GT_REFINERY", "1")
_ = t.SetEnvironment(sessionID, "GT_ROLE", "refinery")
// Set beads environment - refinery uses rig-level beads
beadsDir := filepath.Join(m.rig.Path, "mayor", "rig", ".beads")
_ = t.SetEnvironment(sessionID, "BEADS_DIR", beadsDir)
_ = t.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1")
_ = t.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/refinery", m.rig.Name))
// Apply theme (same as rig polecats)
theme := tmux.AssignTheme(m.rig.Name)
_ = t.ConfigureGasTownSession(sessionID, theme, m.rig.Name, "refinery", "refinery")
// Update state to running
now := time.Now()
ref.State = StateRunning
ref.StartedAt = &now
ref.PID = 0 // Claude agent doesn't have a PID we track
if err := m.saveState(ref); err != nil {
_ = t.KillSession(sessionID)
return fmt.Errorf("saving state: %w", err)
}
// Start Claude agent with full permissions (like polecats)
command := "claude --dangerously-skip-permissions"
if err := t.SendKeys(sessionID, command); err != nil {
// Clean up the session on failure
_ = t.KillSession(sessionID)
return fmt.Errorf("starting Claude agent: %w", err)
}
// Wait for Claude to start (pane command changes from shell to node)
// NOTE: No gt prime injection needed - SessionStart hook handles it automatically
shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"}
if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil {
fmt.Fprintf(m.output, "Warning: Timeout waiting for Claude to start: %v\n", err)
}
return nil
}
// Stop stops the refinery.
func (m *Manager) Stop() error {
ref, err := m.loadState()
if err != nil {
return err
}
// Check if tmux session exists
t := tmux.NewTmux()
sessionID := m.sessionName()
sessionRunning, _ := t.HasSession(sessionID)
// If neither state nor session indicates running, it's not running
if ref.State != StateRunning && !sessionRunning {
return ErrNotRunning
}
// Kill tmux session if it exists
if sessionRunning {
_ = t.KillSession(sessionID)
}
// If we have a PID and it's a different process, try to stop it gracefully
if ref.PID > 0 && ref.PID != os.Getpid() && processExists(ref.PID) {
// Send SIGTERM
if proc, err := os.FindProcess(ref.PID); err == nil {
_ = proc.Signal(os.Interrupt)
}
}
ref.State = StateStopped
ref.PID = 0
return m.saveState(ref)
}
// Queue returns the current merge queue.
func (m *Manager) Queue() ([]QueueItem, error) {
// Discover branches that look like polecat work branches
branches, err := m.discoverWorkBranches()
if err != nil {
return nil, err
}
// Load any pending MRs from state
ref, err := m.loadState()
if err != nil {
return nil, err
}
// Build queue items
var items []QueueItem
pos := 1
// Add current processing item
if ref.CurrentMR != nil {
items = append(items, QueueItem{
Position: 0, // 0 = currently processing
MR: ref.CurrentMR,
Age: formatAge(ref.CurrentMR.CreatedAt),
})
}
// Add discovered branches as pending
for _, branch := range branches {
mr := m.branchToMR(branch)
if mr != nil {
items = append(items, QueueItem{
Position: pos,
MR: mr,
Age: formatAge(mr.CreatedAt),
})
pos++
}
}
return items, nil
}
// discoverWorkBranches finds branches that look like polecat work.
func (m *Manager) discoverWorkBranches() ([]string, error) {
cmd := exec.Command("git", "branch", "-r", "--list", "origin/polecat/*")
cmd.Dir = m.workDir
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, nil // No remote branches
}
var branches []string
for _, line := range strings.Split(stdout.String(), "\n") {
branch := strings.TrimSpace(line)
if branch != "" && !strings.Contains(branch, "->") {
// Remove origin/ prefix
branch = strings.TrimPrefix(branch, "origin/")
branches = append(branches, branch)
}
}
return branches, nil
}
// branchToMR converts a branch name to a merge request.
func (m *Manager) branchToMR(branch string) *MergeRequest {
// Expected format: polecat/<worker>/<issue> or polecat/<worker>
pattern := regexp.MustCompile(`^polecat/([^/]+)(?:/(.+))?$`)
matches := pattern.FindStringSubmatch(branch)
if matches == nil {
return nil
}
worker := matches[1]
issueID := ""
if len(matches) > 2 {
issueID = matches[2]
}
return &MergeRequest{
ID: fmt.Sprintf("mr-%s-%d", worker, time.Now().Unix()),
Branch: branch,
Worker: worker,
IssueID: issueID,
TargetBranch: "main", // Default; swarm would use integration branch
CreatedAt: time.Now(), // Would ideally get from git
Status: MROpen,
}
}
// run is the main processing loop (for foreground mode).
func (m *Manager) run(ref *Refinery) error {
fmt.Fprintln(m.output, "Refinery running...")
fmt.Fprintln(m.output, "Press Ctrl+C to stop")
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
// Process queue
if err := m.ProcessQueue(); err != nil {
fmt.Fprintf(m.output, "Queue processing error: %v\n", err)
}
}
return nil
}
// ProcessQueue processes all pending merge requests.
func (m *Manager) ProcessQueue() error {
queue, err := m.Queue()
if err != nil {
return err
}
for _, item := range queue {
if !item.MR.IsOpen() {
continue
}
fmt.Fprintf(m.output, "Processing: %s (%s)\n", item.MR.Branch, item.MR.Worker)
result := m.ProcessMR(item.MR)
if result.Success {
fmt.Fprintln(m.output, " ✓ Merged successfully")
} else {
fmt.Fprintf(m.output, " ✗ Failed: %s\n", result.Error)
}
}
return nil
}
// MergeResult contains the result of a merge attempt.
type MergeResult struct {
Success bool
MergeCommit string // SHA of merge commit on success
Error string
Conflict bool
TestsFailed bool
}
// ProcessMR processes a single merge request.
func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult {
ref, _ := m.loadState()
config := m.getMergeConfig()
// Claim the MR (open → in_progress)
if err := mr.Claim(); err != nil {
return MergeResult{Error: fmt.Sprintf("cannot claim MR: %v", err)}
}
ref.CurrentMR = mr
_ = m.saveState(ref)
result := MergeResult{}
// 1. Fetch the branch
if err := m.gitRun("fetch", "origin", mr.Branch); err != nil {
result.Error = fmt.Sprintf("fetch failed: %v", err)
m.completeMR(mr, "", result.Error) // Reopen for retry
return result
}
// 2. Checkout target branch
if err := m.gitRun("checkout", mr.TargetBranch); err != nil {
result.Error = fmt.Sprintf("checkout target failed: %v", err)
m.completeMR(mr, "", result.Error) // Reopen for retry
return result
}
// Pull latest
_ = m.gitRun("pull", "origin", mr.TargetBranch) // Ignore errors
// 3. Merge
err := m.gitRun("merge", "--no-ff", "-m",
fmt.Sprintf("Merge %s from %s", mr.Branch, mr.Worker),
"origin/"+mr.Branch)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "CONFLICT") || strings.Contains(errStr, "conflict") {
result.Conflict = true
result.Error = "merge conflict"
// Abort the merge
_ = m.gitRun("merge", "--abort")
m.completeMR(mr, "", "merge conflict - polecat must rebase") // Reopen for rebase
// Notify worker about conflict
m.notifyWorkerConflict(mr)
return result
}
result.Error = fmt.Sprintf("merge failed: %v", err)
m.completeMR(mr, "", result.Error) // Reopen for retry
return result
}
// 4. Run tests if configured
if config.RunTests && config.TestCommand != "" {
if err := m.runTests(config.TestCommand); err != nil {
result.TestsFailed = true
result.Error = fmt.Sprintf("tests failed: %v", err)
// Reset to before merge
_ = m.gitRun("reset", "--hard", "HEAD~1")
m.completeMR(mr, "", result.Error) // Reopen for fixes
return result
}
}
// 5. Push with retry logic
if err := m.pushWithRetry(mr.TargetBranch, config); err != nil {
result.Error = fmt.Sprintf("push failed: %v", err)
// Reset to before merge
_ = m.gitRun("reset", "--hard", "HEAD~1")
m.completeMR(mr, "", result.Error) // Reopen for retry
return result
}
// 6. Get merge commit SHA
mergeCommit, err := m.gitOutput("rev-parse", "HEAD")
if err != nil {
mergeCommit = "" // Non-fatal, continue
}
// Success!
result.Success = true
result.MergeCommit = mergeCommit
m.completeMR(mr, CloseReasonMerged, "")
// Notify worker of success
m.notifyWorkerMerged(mr)
// Optionally delete the merged branch
if config.DeleteMergedBranches {
_ = m.gitRun("push", "origin", "--delete", mr.Branch)
}
return result
}
// completeMR marks an MR as complete and updates stats.
// For success, pass closeReason (e.g., CloseReasonMerged).
// For failures that should return to open, pass empty closeReason.
func (m *Manager) completeMR(mr *MergeRequest, closeReason CloseReason, errMsg string) {
ref, _ := m.loadState()
mr.Error = errMsg
ref.CurrentMR = nil
now := time.Now()
if closeReason != "" {
// Close the MR (in_progress → closed)
if err := mr.Close(closeReason); err != nil {
// Log error but continue - this shouldn't happen
fmt.Fprintf(m.output, "Warning: failed to close MR: %v\n", err)
}
switch closeReason {
case CloseReasonMerged:
ref.LastMergeAt = &now
ref.Stats.TotalMerged++
ref.Stats.TodayMerged++
case CloseReasonSuperseded:
ref.Stats.TotalSkipped++
default:
// Other close reasons (rejected, conflict) count as failed
ref.Stats.TotalFailed++
ref.Stats.TodayFailed++
}
} else {
// Reopen the MR for rework (in_progress → open)
if err := mr.Reopen(); err != nil {
// Log error but continue
fmt.Fprintf(m.output, "Warning: failed to reopen MR: %v\n", err)
}
ref.Stats.TotalFailed++
ref.Stats.TodayFailed++
}
_ = m.saveState(ref)
}
// getTestCommand returns the test command if configured.
func (m *Manager) getTestCommand() string {
// Check settings/config.json for test_command
settingsPath := filepath.Join(m.rig.Path, "settings", "config.json")
settings, err := config.LoadRigSettings(settingsPath)
if err != nil {
return ""
}
if settings.MergeQueue != nil && settings.MergeQueue.TestCommand != "" {
return settings.MergeQueue.TestCommand
}
return ""
}
// runTests executes the test command.
func (m *Manager) runTests(testCmd string) error {
parts := strings.Fields(testCmd)
if len(parts) == 0 {
return nil
}
cmd := exec.Command(parts[0], parts[1:]...)
cmd.Dir = m.workDir
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
// gitRun executes a git command.
func (m *Manager) gitRun(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = m.workDir
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return fmt.Errorf("%s", errMsg)
}
return err
}
return nil
}
// gitOutput executes a git command and returns stdout.
func (m *Manager) gitOutput(args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = m.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return "", fmt.Errorf("%s", errMsg)
}
return "", err
}
return strings.TrimSpace(stdout.String()), nil
}
// getMergeConfig loads the merge configuration from disk.
// Returns default config if not configured.
func (m *Manager) getMergeConfig() MergeConfig {
mergeConfig := DefaultMergeConfig()
// Check settings/config.json for merge_queue settings
settingsPath := filepath.Join(m.rig.Path, "settings", "config.json")
settings, err := config.LoadRigSettings(settingsPath)
if err != nil {
return mergeConfig
}
// Apply merge_queue config if present
if settings.MergeQueue != nil {
mq := settings.MergeQueue
mergeConfig.TestCommand = mq.TestCommand
mergeConfig.RunTests = mq.RunTests
mergeConfig.DeleteMergedBranches = mq.DeleteMergedBranches
// Note: PushRetryCount and PushRetryDelayMs use defaults if not explicitly set
}
return mergeConfig
}
// pushWithRetry pushes to the target branch with exponential backoff retry.
func (m *Manager) pushWithRetry(targetBranch string, config MergeConfig) error {
var lastErr error
delay := time.Duration(config.PushRetryDelayMs) * time.Millisecond
for attempt := 0; attempt <= config.PushRetryCount; attempt++ {
if attempt > 0 {
fmt.Fprintf(m.output, "Push retry %d/%d after %v\n", attempt, config.PushRetryCount, delay)
time.Sleep(delay)
delay *= 2 // Exponential backoff
}
err := m.gitRun("push", "origin", targetBranch)
if err == nil {
return nil // Success
}
lastErr = err
}
return fmt.Errorf("push failed after %d retries: %v", config.PushRetryCount, lastErr)
}
// processExists checks if a process with the given PID exists.
func processExists(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
// On Unix, FindProcess always succeeds; signal 0 tests existence
err = proc.Signal(nil)
return err == nil
}
// formatAge formats a duration since the given time.
func formatAge(t time.Time) string {
d := time.Since(t)
if d < time.Minute {
return fmt.Sprintf("%ds ago", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm ago", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
// notifyWorkerConflict sends a conflict notification to a polecat.
func (m *Manager) notifyWorkerConflict(mr *MergeRequest) {
router := mail.NewRouter(m.workDir)
msg := &mail.Message{
From: fmt.Sprintf("%s/refinery", m.rig.Name),
To: fmt.Sprintf("%s/%s", m.rig.Name, mr.Worker),
Subject: "Merge conflict - rebase required",
Body: fmt.Sprintf(`Your branch %s has conflicts with %s.
Please rebase your changes:
git fetch origin
git rebase origin/%s
git push -f
Then the Refinery will retry the merge.`,
mr.Branch, mr.TargetBranch, mr.TargetBranch),
Priority: mail.PriorityHigh,
}
_ = router.Send(msg)
}
// notifyWorkerMerged sends a success notification to a polecat.
func (m *Manager) notifyWorkerMerged(mr *MergeRequest) {
router := mail.NewRouter(m.workDir)
msg := &mail.Message{
From: fmt.Sprintf("%s/refinery", m.rig.Name),
To: fmt.Sprintf("%s/%s", m.rig.Name, mr.Worker),
Subject: "Work merged successfully",
Body: fmt.Sprintf(`Your branch %s has been merged to %s.
Issue: %s
Thank you for your contribution!`,
mr.Branch, mr.TargetBranch, mr.IssueID),
}
_ = router.Send(msg)
}
// Common errors for MR operations
var (
ErrMRNotFound = errors.New("merge request not found")
ErrMRNotFailed = errors.New("merge request has not failed")
)
// GetMR returns a merge request by ID from the state.
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
}
// FindMR finds a merge request by ID or branch name in the queue.
func (m *Manager) FindMR(idOrBranch string) (*MergeRequest, error) {
queue, err := m.Queue()
if err != nil {
return nil, err
}
for _, item := range queue {
// Match by ID
if item.MR.ID == idOrBranch {
return item.MR, nil
}
// Match by branch name (with or without polecat/ prefix)
if item.MR.Branch == idOrBranch {
return item.MR, nil
}
if "polecat/"+idOrBranch == item.MR.Branch {
return item.MR, nil
}
// Match by worker name (partial match for convenience)
if strings.Contains(item.MR.ID, idOrBranch) {
return item.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)
}
// RejectMR manually rejects a merge request.
// It closes the MR with rejected status and optionally notifies the worker.
// Returns the rejected MR for display purposes.
func (m *Manager) RejectMR(idOrBranch string, reason string, notify bool) (*MergeRequest, error) {
mr, err := m.FindMR(idOrBranch)
if err != nil {
return nil, err
}
// Verify MR is open or in_progress (can't reject already closed)
if mr.IsClosed() {
return nil, fmt.Errorf("%w: MR is already closed with reason: %s", ErrClosedImmutable, mr.CloseReason)
}
// Close with rejected reason
if err := mr.Close(CloseReasonRejected); err != nil {
return nil, fmt.Errorf("failed to close MR: %w", err)
}
mr.Error = reason
// Optionally notify worker
if notify {
m.notifyWorkerRejected(mr, reason)
}
return mr, nil
}
// notifyWorkerRejected sends a rejection notification to a polecat.
func (m *Manager) notifyWorkerRejected(mr *MergeRequest, reason string) {
router := mail.NewRouter(m.workDir)
msg := &mail.Message{
From: fmt.Sprintf("%s/refinery", m.rig.Name),
To: fmt.Sprintf("%s/%s", m.rig.Name, mr.Worker),
Subject: "Merge request rejected",
Body: fmt.Sprintf(`Your merge request has been rejected.
Branch: %s
Issue: %s
Reason: %s
Please review the feedback and address the issues before resubmitting.`,
mr.Branch, mr.IssueID, reason),
Priority: mail.PriorityNormal,
}
_ = router.Send(msg)
}
// findTownRoot walks up directories to find the town root.
func findTownRoot(startPath string) string {
path := startPath
for {
// Check for mayor/ subdirectory (indicates town root)
if _, err := os.Stat(filepath.Join(path, "mayor")); err == nil {
return path
}
// Check for config.json with type: workspace
configPath := filepath.Join(path, "config.json")
if data, err := os.ReadFile(configPath); err == nil {
if strings.Contains(string(data), `"type": "workspace"`) {
return path
}
}
parent := filepath.Dir(path)
if parent == path {
break // Reached root
}
path = parent
}
return ""
}