diff --git a/internal/mail/router.go b/internal/mail/router.go index e51d0681..0669b4b4 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -71,10 +71,8 @@ func (r *Router) Send(msg *Message) error { return fmt.Errorf("sending message: %w", err) } - // Optionally notify if recipient is a polecat with active session - if isPolecat(msg.To) && (msg.Priority == PriorityHigh || msg.Priority == PriorityUrgent) { - r.notifyPolecat(msg) - } + // Notify recipient if they have an active session + r.notifyRecipient(msg) return nil } @@ -84,43 +82,44 @@ func (r *Router) GetMailbox(address string) (*Mailbox, error) { return NewMailboxFromAddress(address, r.workDir), nil } -// notifyPolecat sends a notification to a polecat's tmux session. -func (r *Router) notifyPolecat(msg *Message) error { - // Parse rig/polecat from address - parts := strings.SplitN(msg.To, "/", 2) - if len(parts) != 2 { - return nil +// notifyRecipient sends a notification to a recipient's tmux session. +// Uses display-message for non-disruptive notification. +// Supports mayor/, rig/polecat, and rig/refinery addresses. +func (r *Router) notifyRecipient(msg *Message) error { + sessionID := addressToSessionID(msg.To) + if sessionID == "" { + return nil // Unable to determine session ID } - rig := parts[0] - polecat := parts[1] - - // Generate session name (matches session.Manager) - sessionID := fmt.Sprintf("gt-%s-%s", rig, polecat) - // Check if session exists hasSession, err := r.tmux.HasSession(sessionID) if err != nil || !hasSession { return nil // No active session, skip notification } - // Inject notification - notification := fmt.Sprintf("[MAIL] %s", msg.Subject) - return r.tmux.SendKeys(sessionID, notification) + // Display notification in status line (non-disruptive) + notification := fmt.Sprintf("[MAIL] From %s: %s", msg.From, msg.Subject) + return r.tmux.DisplayMessageDefault(sessionID, notification) } -// isPolecat checks if an address points to a polecat. -func isPolecat(address string) bool { - // Not mayor, not refinery, has rig/name format +// addressToSessionID converts a mail address to a tmux session ID. +// Returns empty string if address format is not recognized. +func addressToSessionID(address string) string { + // Mayor address: "mayor/" or "mayor" if strings.HasPrefix(address, "mayor") { - return false + return "gt-mayor" } + // Rig-based address: "rig/target" parts := strings.SplitN(address, "/", 2) - if len(parts) != 2 { - return false + if len(parts) != 2 || parts[1] == "" { + return "" } + rig := parts[0] target := parts[1] - return target != "" && target != "refinery" + + // Polecat: gt-rig-polecat + // Refinery: gt-rig-refinery (if refinery has its own session) + return fmt.Sprintf("gt-%s-%s", rig, target) } diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index d29c894e..f9e3d1c5 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -294,15 +294,17 @@ func (m *Manager) ProcessQueue() error { // MergeResult contains the result of a merge attempt. type MergeResult struct { - Success bool - Error string - Conflict bool + 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 { @@ -320,8 +322,7 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult { return result } - // 2. Attempt merge to target branch - // First, checkout target + // 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 @@ -331,7 +332,7 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult { // Pull latest m.gitRun("pull", "origin", mr.TargetBranch) // Ignore errors - // Merge + // 3. Merge err := m.gitRun("merge", "--no-ff", "-m", fmt.Sprintf("Merge %s from %s", mr.Branch, mr.Worker), "origin/"+mr.Branch) @@ -353,10 +354,9 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult { return result } - // 3. Run tests if configured - testCmd := m.getTestCommand() - if testCmd != "" { - if err := m.runTests(testCmd); err != nil { + // 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 @@ -366,8 +366,8 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult { } } - // 4. Push - if err := m.gitRun("push", "origin", mr.TargetBranch); err != nil { + // 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") @@ -375,15 +375,24 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult { 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 - m.gitRun("push", "origin", "--delete", mr.Branch) + if config.DeleteMergedBranches { + m.gitRun("push", "origin", "--delete", mr.Branch) + } return result } @@ -487,6 +496,89 @@ func (m *Manager) gitRun(args ...string) error { 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 { + config := DefaultMergeConfig() + + // Check for .gastown/config.json with merge_queue settings + configPath := filepath.Join(m.rig.Path, ".gastown", "config.json") + data, err := os.ReadFile(configPath) + if err != nil { + return config + } + + var rawConfig struct { + MergeQueue *MergeConfig `json:"merge_queue"` + // Legacy field for backwards compatibility + TestCommand string `json:"test_command"` + } + if err := json.Unmarshal(data, &rawConfig); err != nil { + return config + } + + // Apply merge_queue config if present + if rawConfig.MergeQueue != nil { + config = *rawConfig.MergeQueue + // Ensure defaults for zero values + if config.PushRetryCount == 0 { + config.PushRetryCount = 3 + } + if config.PushRetryDelayMs == 0 { + config.PushRetryDelayMs = 1000 + } + } + + // Legacy: use test_command if merge_queue not set + if rawConfig.TestCommand != "" && config.TestCommand == "" { + config.TestCommand = rawConfig.TestCommand + } + + return config +} + +// 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.Printf("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) diff --git a/internal/refinery/types.go b/internal/refinery/types.go index 6d220a00..550d4f12 100644 --- a/internal/refinery/types.go +++ b/internal/refinery/types.go @@ -115,6 +115,41 @@ const ( ) +// MergeConfig contains configuration for the merge process. +type MergeConfig struct { + // RunTests controls whether tests are run after merge. + // Default: true + RunTests bool `json:"run_tests"` + + // TestCommand is the command to run for testing. + // Default: "go test ./..." + TestCommand string `json:"test_command"` + + // DeleteMergedBranches controls whether merged branches are deleted. + // Default: true + DeleteMergedBranches bool `json:"delete_merged_branches"` + + // PushRetryCount is the number of times to retry a failed push. + // Default: 3 + PushRetryCount int `json:"push_retry_count"` + + // PushRetryDelayMs is the base delay between push retries in milliseconds. + // Each retry doubles the delay (exponential backoff). + // Default: 1000 + PushRetryDelayMs int `json:"push_retry_delay_ms"` +} + +// DefaultMergeConfig returns the default merge configuration. +func DefaultMergeConfig() MergeConfig { + return MergeConfig{ + RunTests: true, + TestCommand: "go test ./...", + DeleteMergedBranches: true, + PushRetryCount: 3, + PushRetryDelayMs: 1000, + } +} + // RefineryStats contains cumulative refinery statistics. type RefineryStats struct { // TotalMerged is the total number of successful merges. diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 4070a69a..dddaa3cb 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -200,6 +200,21 @@ type SessionInfo struct { Attached bool } +// DisplayMessage shows a message in the tmux status line. +// This is non-disruptive - it doesn't interrupt the session's input. +// Duration is specified in milliseconds. +func (t *Tmux) DisplayMessage(session, message string, durationMs int) error { + // Set display time temporarily, show message, then restore + // Use -d flag for duration in tmux 2.9+ + _, err := t.run("display-message", "-t", session, "-d", fmt.Sprintf("%d", durationMs), message) + return err +} + +// DisplayMessageDefault shows a message with default duration (5 seconds). +func (t *Tmux) DisplayMessageDefault(session, message string) error { + return t.DisplayMessage(session, message, 5000) +} + // GetSessionInfo returns detailed information about a session. func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) { format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}"