Merge polecat/Razor: tmux notifications and merge execution
Adds: - gt mail send now triggers tmux notification for recipients - Merge execution with config and retry logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,10 +71,8 @@ func (r *Router) Send(msg *Message) error {
|
|||||||
return fmt.Errorf("sending message: %w", err)
|
return fmt.Errorf("sending message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally notify if recipient is a polecat with active session
|
// Notify recipient if they have an active session
|
||||||
if isPolecat(msg.To) && (msg.Priority == PriorityHigh || msg.Priority == PriorityUrgent) {
|
r.notifyRecipient(msg)
|
||||||
r.notifyPolecat(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -84,43 +82,44 @@ func (r *Router) GetMailbox(address string) (*Mailbox, error) {
|
|||||||
return NewMailboxFromAddress(address, r.workDir), nil
|
return NewMailboxFromAddress(address, r.workDir), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyPolecat sends a notification to a polecat's tmux session.
|
// notifyRecipient sends a notification to a recipient's tmux session.
|
||||||
func (r *Router) notifyPolecat(msg *Message) error {
|
// Uses display-message for non-disruptive notification.
|
||||||
// Parse rig/polecat from address
|
// Supports mayor/, rig/polecat, and rig/refinery addresses.
|
||||||
parts := strings.SplitN(msg.To, "/", 2)
|
func (r *Router) notifyRecipient(msg *Message) error {
|
||||||
if len(parts) != 2 {
|
sessionID := addressToSessionID(msg.To)
|
||||||
return nil
|
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
|
// Check if session exists
|
||||||
hasSession, err := r.tmux.HasSession(sessionID)
|
hasSession, err := r.tmux.HasSession(sessionID)
|
||||||
if err != nil || !hasSession {
|
if err != nil || !hasSession {
|
||||||
return nil // No active session, skip notification
|
return nil // No active session, skip notification
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject notification
|
// Display notification in status line (non-disruptive)
|
||||||
notification := fmt.Sprintf("[MAIL] %s", msg.Subject)
|
notification := fmt.Sprintf("[MAIL] From %s: %s", msg.From, msg.Subject)
|
||||||
return r.tmux.SendKeys(sessionID, notification)
|
return r.tmux.DisplayMessageDefault(sessionID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPolecat checks if an address points to a polecat.
|
// addressToSessionID converts a mail address to a tmux session ID.
|
||||||
func isPolecat(address string) bool {
|
// Returns empty string if address format is not recognized.
|
||||||
// Not mayor, not refinery, has rig/name format
|
func addressToSessionID(address string) string {
|
||||||
|
// Mayor address: "mayor/" or "mayor"
|
||||||
if strings.HasPrefix(address, "mayor") {
|
if strings.HasPrefix(address, "mayor") {
|
||||||
return false
|
return "gt-mayor"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rig-based address: "rig/target"
|
||||||
parts := strings.SplitN(address, "/", 2)
|
parts := strings.SplitN(address, "/", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 || parts[1] == "" {
|
||||||
return false
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rig := parts[0]
|
||||||
target := parts[1]
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,15 +294,17 @@ func (m *Manager) ProcessQueue() error {
|
|||||||
|
|
||||||
// MergeResult contains the result of a merge attempt.
|
// MergeResult contains the result of a merge attempt.
|
||||||
type MergeResult struct {
|
type MergeResult struct {
|
||||||
Success bool
|
Success bool
|
||||||
Error string
|
MergeCommit string // SHA of merge commit on success
|
||||||
Conflict bool
|
Error string
|
||||||
|
Conflict bool
|
||||||
TestsFailed bool
|
TestsFailed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessMR processes a single merge request.
|
// ProcessMR processes a single merge request.
|
||||||
func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult {
|
func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult {
|
||||||
ref, _ := m.loadState()
|
ref, _ := m.loadState()
|
||||||
|
config := m.getMergeConfig()
|
||||||
|
|
||||||
// Claim the MR (open → in_progress)
|
// Claim the MR (open → in_progress)
|
||||||
if err := mr.Claim(); err != nil {
|
if err := mr.Claim(); err != nil {
|
||||||
@@ -320,8 +322,7 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Attempt merge to target branch
|
// 2. Checkout target branch
|
||||||
// First, checkout target
|
|
||||||
if err := m.gitRun("checkout", mr.TargetBranch); err != nil {
|
if err := m.gitRun("checkout", mr.TargetBranch); err != nil {
|
||||||
result.Error = fmt.Sprintf("checkout target failed: %v", err)
|
result.Error = fmt.Sprintf("checkout target failed: %v", err)
|
||||||
m.completeMR(mr, "", result.Error) // Reopen for retry
|
m.completeMR(mr, "", result.Error) // Reopen for retry
|
||||||
@@ -331,7 +332,7 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult {
|
|||||||
// Pull latest
|
// Pull latest
|
||||||
m.gitRun("pull", "origin", mr.TargetBranch) // Ignore errors
|
m.gitRun("pull", "origin", mr.TargetBranch) // Ignore errors
|
||||||
|
|
||||||
// Merge
|
// 3. Merge
|
||||||
err := m.gitRun("merge", "--no-ff", "-m",
|
err := m.gitRun("merge", "--no-ff", "-m",
|
||||||
fmt.Sprintf("Merge %s from %s", mr.Branch, mr.Worker),
|
fmt.Sprintf("Merge %s from %s", mr.Branch, mr.Worker),
|
||||||
"origin/"+mr.Branch)
|
"origin/"+mr.Branch)
|
||||||
@@ -353,10 +354,9 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Run tests if configured
|
// 4. Run tests if configured
|
||||||
testCmd := m.getTestCommand()
|
if config.RunTests && config.TestCommand != "" {
|
||||||
if testCmd != "" {
|
if err := m.runTests(config.TestCommand); err != nil {
|
||||||
if err := m.runTests(testCmd); err != nil {
|
|
||||||
result.TestsFailed = true
|
result.TestsFailed = true
|
||||||
result.Error = fmt.Sprintf("tests failed: %v", err)
|
result.Error = fmt.Sprintf("tests failed: %v", err)
|
||||||
// Reset to before merge
|
// Reset to before merge
|
||||||
@@ -366,8 +366,8 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Push
|
// 5. Push with retry logic
|
||||||
if err := m.gitRun("push", "origin", mr.TargetBranch); err != nil {
|
if err := m.pushWithRetry(mr.TargetBranch, config); err != nil {
|
||||||
result.Error = fmt.Sprintf("push failed: %v", err)
|
result.Error = fmt.Sprintf("push failed: %v", err)
|
||||||
// Reset to before merge
|
// Reset to before merge
|
||||||
m.gitRun("reset", "--hard", "HEAD~1")
|
m.gitRun("reset", "--hard", "HEAD~1")
|
||||||
@@ -375,15 +375,24 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. Get merge commit SHA
|
||||||
|
mergeCommit, err := m.gitOutput("rev-parse", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
mergeCommit = "" // Non-fatal, continue
|
||||||
|
}
|
||||||
|
|
||||||
// Success!
|
// Success!
|
||||||
result.Success = true
|
result.Success = true
|
||||||
|
result.MergeCommit = mergeCommit
|
||||||
m.completeMR(mr, CloseReasonMerged, "")
|
m.completeMR(mr, CloseReasonMerged, "")
|
||||||
|
|
||||||
// Notify worker of success
|
// Notify worker of success
|
||||||
m.notifyWorkerMerged(mr)
|
m.notifyWorkerMerged(mr)
|
||||||
|
|
||||||
// Optionally delete the merged branch
|
// Optionally delete the merged branch
|
||||||
m.gitRun("push", "origin", "--delete", mr.Branch)
|
if config.DeleteMergedBranches {
|
||||||
|
m.gitRun("push", "origin", "--delete", mr.Branch)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -487,6 +496,89 @@ func (m *Manager) gitRun(args ...string) error {
|
|||||||
return nil
|
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.
|
// processExists checks if a process with the given PID exists.
|
||||||
func processExists(pid int) bool {
|
func processExists(pid int) bool {
|
||||||
proc, err := os.FindProcess(pid)
|
proc, err := os.FindProcess(pid)
|
||||||
|
|||||||
@@ -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.
|
// RefineryStats contains cumulative refinery statistics.
|
||||||
type RefineryStats struct {
|
type RefineryStats struct {
|
||||||
// TotalMerged is the total number of successful merges.
|
// TotalMerged is the total number of successful merges.
|
||||||
|
|||||||
@@ -200,6 +200,21 @@ type SessionInfo struct {
|
|||||||
Attached bool
|
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.
|
// GetSessionInfo returns detailed information about a session.
|
||||||
func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) {
|
func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) {
|
||||||
format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}"
|
format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}"
|
||||||
|
|||||||
Reference in New Issue
Block a user