feat(escalate): align config schema with design doc

- Change EscalationConfig to use Routes map with action strings
- Rename severity "normal" to "medium" per design doc
- Move config from config/ to settings/escalation.json
- Add --source flag for escalation source tracking
- Add Source field to EscalationFields
- Add executeExternalActions() for email/sms/slack with warnings
- Add default escalation config creation in gt install
- Add comprehensive unit tests for config loading
- Update help text with correct severity levels and paths

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-12 02:29:56 -08:00
committed by beads/crew/emma
parent b9ecb7b82e
commit 9779ae3190
7 changed files with 882 additions and 163 deletions

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -13,15 +14,20 @@ import (
// EscalationFields holds structured fields for escalation beads. // EscalationFields holds structured fields for escalation beads.
// These are stored as "key: value" lines in the description. // These are stored as "key: value" lines in the description.
type EscalationFields struct { type EscalationFields struct {
Severity string // critical, high, normal, low Severity string // critical, high, medium, low
Reason string // Why this was escalated Reason string // Why this was escalated
EscalatedBy string // Agent address that escalated (e.g., "gastown/Toast") Source string // Source identifier (e.g., plugin:rebuild-gt, patrol:deacon)
EscalatedAt string // ISO 8601 timestamp EscalatedBy string // Agent address that escalated (e.g., "gastown/Toast")
AckedBy string // Agent that acknowledged (empty if not acked) EscalatedAt string // ISO 8601 timestamp
AckedAt string // When acknowledged (empty if not acked) AckedBy string // Agent that acknowledged (empty if not acked)
ClosedBy string // Agent that closed (empty if not closed) AckedAt string // When acknowledged (empty if not acked)
ClosedReason string // Resolution reason (empty if not closed) ClosedBy string // Agent that closed (empty if not closed)
RelatedBead string // Optional: related bead ID (task, bug, etc.) ClosedReason string // Resolution reason (empty if not closed)
RelatedBead string // Optional: related bead ID (task, bug, etc.)
OriginalSeverity string // Original severity before any re-escalation
ReescalationCount int // Number of times this has been re-escalated
LastReescalatedAt string // When last re-escalated (empty if never)
LastReescalatedBy string // Who last re-escalated (empty if never)
} }
// EscalationState constants for bead status tracking. // EscalationState constants for bead status tracking.
@@ -42,6 +48,11 @@ func FormatEscalationDescription(title string, fields *EscalationFields) string
lines = append(lines, "") lines = append(lines, "")
lines = append(lines, fmt.Sprintf("severity: %s", fields.Severity)) lines = append(lines, fmt.Sprintf("severity: %s", fields.Severity))
lines = append(lines, fmt.Sprintf("reason: %s", fields.Reason)) lines = append(lines, fmt.Sprintf("reason: %s", fields.Reason))
if fields.Source != "" {
lines = append(lines, fmt.Sprintf("source: %s", fields.Source))
} else {
lines = append(lines, "source: null")
}
lines = append(lines, fmt.Sprintf("escalated_by: %s", fields.EscalatedBy)) lines = append(lines, fmt.Sprintf("escalated_by: %s", fields.EscalatedBy))
lines = append(lines, fmt.Sprintf("escalated_at: %s", fields.EscalatedAt)) lines = append(lines, fmt.Sprintf("escalated_at: %s", fields.EscalatedAt))
@@ -75,6 +86,24 @@ func FormatEscalationDescription(title string, fields *EscalationFields) string
lines = append(lines, "related_bead: null") lines = append(lines, "related_bead: null")
} }
// Reescalation fields
if fields.OriginalSeverity != "" {
lines = append(lines, fmt.Sprintf("original_severity: %s", fields.OriginalSeverity))
} else {
lines = append(lines, "original_severity: null")
}
lines = append(lines, fmt.Sprintf("reescalation_count: %d", fields.ReescalationCount))
if fields.LastReescalatedAt != "" {
lines = append(lines, fmt.Sprintf("last_reescalated_at: %s", fields.LastReescalatedAt))
} else {
lines = append(lines, "last_reescalated_at: null")
}
if fields.LastReescalatedBy != "" {
lines = append(lines, fmt.Sprintf("last_reescalated_by: %s", fields.LastReescalatedBy))
} else {
lines = append(lines, "last_reescalated_by: null")
}
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
@@ -104,6 +133,8 @@ func ParseEscalationFields(description string) *EscalationFields {
fields.Severity = value fields.Severity = value
case "reason": case "reason":
fields.Reason = value fields.Reason = value
case "source":
fields.Source = value
case "escalated_by": case "escalated_by":
fields.EscalatedBy = value fields.EscalatedBy = value
case "escalated_at": case "escalated_at":
@@ -118,6 +149,16 @@ func ParseEscalationFields(description string) *EscalationFields {
fields.ClosedReason = value fields.ClosedReason = value
case "related_bead": case "related_bead":
fields.RelatedBead = value fields.RelatedBead = value
case "original_severity":
fields.OriginalSeverity = value
case "reescalation_count":
if n, err := strconv.Atoi(value); err == nil {
fields.ReescalationCount = n
}
case "last_reescalated_at":
fields.LastReescalatedAt = value
case "last_reescalated_by":
fields.LastReescalatedBy = value
} }
} }
@@ -307,3 +348,94 @@ func (b *Beads) ListStaleEscalations(threshold time.Duration) ([]*Issue, error)
return stale, nil return stale, nil
} }
// ReescalationResult holds the result of a reescalation operation.
type ReescalationResult struct {
ID string
Title string
OldSeverity string
NewSeverity string
ReescalationNum int
Skipped bool
SkipReason string
}
// ReescalateEscalation bumps the severity of an escalation and updates tracking fields.
// Returns the new severity if successful, or an error.
// reescalatedBy should be the identity of the agent/process doing the reescalation.
// maxReescalations limits how many times an escalation can be bumped (0 = unlimited).
func (b *Beads) ReescalateEscalation(id, reescalatedBy string, maxReescalations int) (*ReescalationResult, error) {
// Get the escalation
issue, fields, err := b.GetEscalationBead(id)
if err != nil {
return nil, err
}
if issue == nil {
return nil, fmt.Errorf("escalation not found: %s", id)
}
result := &ReescalationResult{
ID: id,
Title: issue.Title,
OldSeverity: fields.Severity,
}
// Check if already at max reescalations
if maxReescalations > 0 && fields.ReescalationCount >= maxReescalations {
result.Skipped = true
result.SkipReason = fmt.Sprintf("already at max reescalations (%d)", maxReescalations)
return result, nil
}
// Check if already at critical (can't bump further)
if fields.Severity == "critical" {
result.Skipped = true
result.SkipReason = "already at critical severity"
result.NewSeverity = "critical"
return result, nil
}
// Save original severity on first reescalation
if fields.OriginalSeverity == "" {
fields.OriginalSeverity = fields.Severity
}
// Bump severity
newSeverity := bumpSeverity(fields.Severity)
fields.Severity = newSeverity
fields.ReescalationCount++
fields.LastReescalatedAt = time.Now().Format(time.RFC3339)
fields.LastReescalatedBy = reescalatedBy
result.NewSeverity = newSeverity
result.ReescalationNum = fields.ReescalationCount
// Format new description
description := FormatEscalationDescription(issue.Title, fields)
// Update the bead with new description and severity label
if err := b.Update(id, UpdateOptions{
Description: &description,
AddLabels: []string{"reescalated", "severity:" + newSeverity},
RemoveLabels: []string{"severity:" + result.OldSeverity},
}); err != nil {
return nil, fmt.Errorf("updating escalation: %w", err)
}
return result, nil
}
// bumpSeverity returns the next higher severity level.
// low -> medium -> high -> critical
func bumpSeverity(severity string) string {
switch severity {
case "low":
return "medium"
case "medium":
return "high"
case "high":
return "critical"
default:
return "critical"
}
}

View File

@@ -8,6 +8,7 @@ import (
var ( var (
escalateSeverity string escalateSeverity string
escalateReason string escalateReason string
escalateSource string
escalateRelatedBead string escalateRelatedBead string
escalateJSON bool escalateJSON bool
escalateListJSON bool escalateListJSON bool
@@ -30,30 +31,31 @@ human or mayor attention. Escalations are tracked as beads with gt:escalation la
SEVERITY LEVELS: SEVERITY LEVELS:
critical (P0) Immediate attention required critical (P0) Immediate attention required
high (P1) Urgent, needs attention soon high (P1) Urgent, needs attention soon
normal (P2) Standard escalation (default) medium (P2) Standard escalation (default)
low (P3) Informational, can wait low (P3) Informational, can wait
WORKFLOW: WORKFLOW:
1. Agent encounters blocking issue 1. Agent encounters blocking issue
2. Runs: gt escalate "Description" --severity high --reason "details" 2. Runs: gt escalate "Description" --severity high --reason "details"
3. Escalation is routed based on config/escalation.json 3. Escalation is routed based on settings/escalation.json
4. Recipient acknowledges with: gt escalate ack <id> 4. Recipient acknowledges with: gt escalate ack <id>
5. After resolution: gt escalate close <id> --reason "fixed" 5. After resolution: gt escalate close <id> --reason "fixed"
CONFIGURATION: CONFIGURATION:
Routing is configured in ~/gt/config/escalation.json: Routing is configured in ~/gt/settings/escalation.json:
- severity_routes: Map severity to notification targets - routes: Map severity to action lists (bead, mail:mayor, email:human, sms:human)
- external_channels: Optional email/SMS for critical issues - contacts: Human email/SMS for external notifications
- stale_threshold: When unacked escalations are flagged - stale_threshold: When unacked escalations are re-escalated (default: 4h)
- max_reescalations: How many times to bump severity (default: 2)
Examples: Examples:
gt escalate "Build failing" --severity critical --reason "CI blocked" gt escalate "Build failing" --severity critical --reason "CI blocked"
gt escalate "Need API credentials" --severity high gt escalate "Need API credentials" --severity high --source "plugin:rebuild-gt"
gt escalate "Code review requested" --reason "PR #123 ready" gt escalate "Code review requested" --reason "PR #123 ready"
gt escalate list # Show open escalations gt escalate list # Show open escalations
gt escalate ack hq-abc123 # Acknowledge gt escalate ack hq-abc123 # Acknowledge
gt escalate close hq-abc123 --reason "Fixed in commit abc" gt escalate close hq-abc123 --reason "Fixed in commit abc"
gt escalate stale # Show unacked escalations`, gt escalate stale # Re-escalate stale escalations`,
} }
var escalateListCmd = &cobra.Command{ var escalateListCmd = &cobra.Command{
@@ -101,15 +103,23 @@ Examples:
var escalateStaleCmd = &cobra.Command{ var escalateStaleCmd = &cobra.Command{
Use: "stale", Use: "stale",
Short: "Show stale unacknowledged escalations", Short: "Re-escalate stale unacknowledged escalations",
Long: `Show escalations that haven't been acknowledged within the threshold. Long: `Find and re-escalate escalations that haven't been acknowledged within the threshold.
The threshold is configured in config/escalation.json (default: 1 hour). When run without --dry-run, this command:
Useful for patrol agents to detect escalations that need attention. 1. Finds escalations older than the stale threshold (default: 4h)
2. Bumps their severity: low→medium→high→critical
3. Re-routes them according to the new severity level
4. Sends mail to the new routing targets
Respects max_reescalations from config (default: 2) to prevent infinite escalation.
The threshold is configured in settings/escalation.json.
Examples: Examples:
gt escalate stale # Show stale escalations gt escalate stale # Re-escalate stale escalations
gt escalate stale --json # JSON output`, gt escalate stale --dry-run # Show what would be done
gt escalate stale --json # JSON output of results`,
RunE: runEscalateStale, RunE: runEscalateStale,
} }
@@ -127,8 +137,9 @@ Examples:
func init() { func init() {
// Main escalate command flags // Main escalate command flags
escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", "normal", "Severity level: critical, high, normal, low") escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", "medium", "Severity level: critical, high, medium, low")
escalateCmd.Flags().StringVarP(&escalateReason, "reason", "r", "", "Detailed reason for escalation") escalateCmd.Flags().StringVarP(&escalateReason, "reason", "r", "", "Detailed reason for escalation")
escalateCmd.Flags().StringVar(&escalateSource, "source", "", "Source identifier (e.g., plugin:rebuild-gt, patrol:deacon)")
escalateCmd.Flags().StringVar(&escalateRelatedBead, "related", "", "Related bead ID (task, bug, etc.)") escalateCmd.Flags().StringVar(&escalateRelatedBead, "related", "", "Related bead ID (task, bug, etc.)")
escalateCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON") escalateCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON")
escalateCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false, "Show what would be done without executing") escalateCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false, "Show what would be done without executing")
@@ -143,6 +154,7 @@ func init() {
// Stale subcommand flags // Stale subcommand flags
escalateStaleCmd.Flags().BoolVar(&escalateStaleJSON, "json", false, "Output as JSON") escalateStaleCmd.Flags().BoolVar(&escalateStaleJSON, "json", false, "Output as JSON")
escalateStaleCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false, "Show what would be re-escalated without acting")
// Show subcommand flags // Show subcommand flags
escalateShowCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON") escalateShowCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON")

View File

@@ -26,14 +26,8 @@ func runEscalate(cmd *cobra.Command, args []string) error {
// Validate severity // Validate severity
severity := strings.ToLower(escalateSeverity) severity := strings.ToLower(escalateSeverity)
validSeverities := map[string]bool{ if !config.IsValidSeverity(severity) {
config.SeverityCritical: true, return fmt.Errorf("invalid severity '%s': must be critical, high, medium, or low", escalateSeverity)
config.SeverityHigh: true,
config.SeverityNormal: true,
config.SeverityLow: true,
}
if !validSeverities[severity] {
return fmt.Errorf("invalid severity '%s': must be critical, high, normal, or low", escalateSeverity)
} }
// Find workspace // Find workspace
@@ -48,10 +42,6 @@ func runEscalate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("loading escalation config: %w", err) return fmt.Errorf("loading escalation config: %w", err)
} }
if !escalationConfig.Enabled {
return fmt.Errorf("escalation system is disabled in config")
}
// Detect agent identity // Detect agent identity
agentID := detectSender() agentID := detectSender()
if agentID == "" { if agentID == "" {
@@ -60,17 +50,19 @@ func runEscalate(cmd *cobra.Command, args []string) error {
// Dry run mode // Dry run mode
if escalateDryRun { if escalateDryRun {
route := escalationConfig.GetRouteForSeverity(severity) actions := escalationConfig.GetRouteForSeverity(severity)
targets := extractMailTargetsFromActions(actions)
fmt.Printf("Would create escalation:\n") fmt.Printf("Would create escalation:\n")
fmt.Printf(" Severity: %s\n", severity) fmt.Printf(" Severity: %s\n", severity)
fmt.Printf(" Description: %s\n", description) fmt.Printf(" Description: %s\n", description)
if escalateReason != "" { if escalateReason != "" {
fmt.Printf(" Reason: %s\n", escalateReason) fmt.Printf(" Reason: %s\n", escalateReason)
} }
fmt.Printf(" Targets: %s\n", strings.Join(route.Targets, ", ")) if escalateSource != "" {
if route.UseExternal { fmt.Printf(" Source: %s\n", escalateSource)
fmt.Printf(" External: enabled\n")
} }
fmt.Printf(" Actions: %s\n", strings.Join(actions, ", "))
fmt.Printf(" Mail targets: %s\n", strings.Join(targets, ", "))
return nil return nil
} }
@@ -79,6 +71,7 @@ func runEscalate(cmd *cobra.Command, args []string) error {
fields := &beads.EscalationFields{ fields := &beads.EscalationFields{
Severity: severity, Severity: severity,
Reason: escalateReason, Reason: escalateReason,
Source: escalateSource,
EscalatedBy: agentID, EscalatedBy: agentID,
EscalatedAt: time.Now().Format(time.RFC3339), EscalatedAt: time.Now().Format(time.RFC3339),
RelatedBead: escalateRelatedBead, RelatedBead: escalateRelatedBead,
@@ -89,12 +82,13 @@ func runEscalate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("creating escalation bead: %w", err) return fmt.Errorf("creating escalation bead: %w", err)
} }
// Get routing for this severity // Get routing actions for this severity
route := escalationConfig.GetRouteForSeverity(severity) actions := escalationConfig.GetRouteForSeverity(severity)
targets := extractMailTargetsFromActions(actions)
// Send mail to each target // Send mail to each target (actions with "mail:" prefix)
router := mail.NewRouter(townRoot) router := mail.NewRouter(townRoot)
for _, target := range route.Targets { for _, target := range targets {
msg := &mail.Message{ msg := &mail.Message{
From: agentID, From: agentID,
To: target, To: target,
@@ -109,7 +103,7 @@ func runEscalate(cmd *cobra.Command, args []string) error {
msg.Priority = mail.PriorityUrgent msg.Priority = mail.PriorityUrgent
case config.SeverityHigh: case config.SeverityHigh:
msg.Priority = mail.PriorityHigh msg.Priority = mail.PriorityHigh
case config.SeverityNormal: case config.SeverityMedium:
msg.Priority = mail.PriorityNormal msg.Priority = mail.PriorityNormal
default: default:
msg.Priority = mail.PriorityLow msg.Priority = mail.PriorityLow
@@ -120,24 +114,39 @@ func runEscalate(cmd *cobra.Command, args []string) error {
} }
} }
// Process external notification actions (email:, sms:, slack)
executeExternalActions(actions, escalationConfig, issue.ID, severity, description)
// Log to activity feed // Log to activity feed
payload := events.EscalationPayload(issue.ID, agentID, strings.Join(route.Targets, ","), description) payload := events.EscalationPayload(issue.ID, agentID, strings.Join(targets, ","), description)
payload["severity"] = severity payload["severity"] = severity
payload["actions"] = strings.Join(actions, ",")
if escalateSource != "" {
payload["source"] = escalateSource
}
_ = events.LogFeed(events.TypeEscalationSent, agentID, payload) _ = events.LogFeed(events.TypeEscalationSent, agentID, payload)
// Output // Output
if escalateJSON { if escalateJSON {
out, _ := json.MarshalIndent(map[string]interface{}{ result := map[string]interface{}{
"id": issue.ID, "id": issue.ID,
"severity": severity, "severity": severity,
"targets": route.Targets, "actions": actions,
}, "", " ") "targets": targets,
}
if escalateSource != "" {
result["source"] = escalateSource
}
out, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(out)) fmt.Println(string(out))
} else { } else {
emoji := severityEmoji(severity) emoji := severityEmoji(severity)
fmt.Printf("%s Escalation created: %s\n", emoji, issue.ID) fmt.Printf("%s Escalation created: %s\n", emoji, issue.ID)
fmt.Printf(" Severity: %s\n", severity) fmt.Printf(" Severity: %s\n", severity)
fmt.Printf(" Routed to: %s\n", strings.Join(route.Targets, ", ")) if escalateSource != "" {
fmt.Printf(" Source: %s\n", escalateSource)
}
fmt.Printf(" Routed to: %s\n", strings.Join(targets, ", "))
} }
return nil return nil
@@ -267,13 +276,14 @@ func runEscalateStale(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace: %w", err) return fmt.Errorf("not in a Gas Town workspace: %w", err)
} }
// Load escalation config for threshold // Load escalation config for threshold and max reescalations
escalationConfig, err := config.LoadOrCreateEscalationConfig(config.EscalationConfigPath(townRoot)) escalationConfig, err := config.LoadOrCreateEscalationConfig(config.EscalationConfigPath(townRoot))
if err != nil { if err != nil {
return fmt.Errorf("loading escalation config: %w", err) return fmt.Errorf("loading escalation config: %w", err)
} }
threshold := escalationConfig.GetStaleThreshold() threshold := escalationConfig.GetStaleThreshold()
maxReescalations := escalationConfig.GetMaxReescalations()
bd := beads.New(beads.ResolveBeadsDir(townRoot)) bd := beads.New(beads.ResolveBeadsDir(townRoot))
stale, err := bd.ListStaleEscalations(threshold) stale, err := bd.ListStaleEscalations(threshold)
@@ -281,31 +291,173 @@ func runEscalateStale(cmd *cobra.Command, args []string) error {
return fmt.Errorf("listing stale escalations: %w", err) return fmt.Errorf("listing stale escalations: %w", err)
} }
if len(stale) == 0 {
if !escalateStaleJSON {
fmt.Printf("No stale escalations (threshold: %s)\n", threshold)
} else {
fmt.Println("[]")
}
return nil
}
// Detect who is reescalating
reescalatedBy := detectSender()
if reescalatedBy == "" {
reescalatedBy = "system"
}
// Dry run mode - just show what would happen
if escalateDryRun {
fmt.Printf("Would re-escalate %d stale escalations (threshold: %s):\n\n", len(stale), threshold)
for _, issue := range stale {
fields := beads.ParseEscalationFields(issue.Description)
newSeverity := getNextSeverity(fields.Severity)
willSkip := maxReescalations > 0 && fields.ReescalationCount >= maxReescalations
if fields.Severity == "critical" {
willSkip = true
}
emoji := severityEmoji(fields.Severity)
if willSkip {
fmt.Printf(" %s %s [SKIP] %s\n", emoji, issue.ID, issue.Title)
if fields.Severity == "critical" {
fmt.Printf(" Already at critical severity\n")
} else {
fmt.Printf(" Already at max reescalations (%d)\n", maxReescalations)
}
} else {
fmt.Printf(" %s %s %s\n", emoji, issue.ID, issue.Title)
fmt.Printf(" %s → %s (reescalation %d/%d)\n",
fields.Severity, newSeverity, fields.ReescalationCount+1, maxReescalations)
}
fmt.Println()
}
return nil
}
// Perform re-escalation
var results []*beads.ReescalationResult
router := mail.NewRouter(townRoot)
for _, issue := range stale {
result, err := bd.ReescalateEscalation(issue.ID, reescalatedBy, maxReescalations)
if err != nil {
style.PrintWarning("failed to reescalate %s: %v", issue.ID, err)
continue
}
results = append(results, result)
// If not skipped, re-route to new severity targets
if !result.Skipped {
actions := escalationConfig.GetRouteForSeverity(result.NewSeverity)
targets := extractMailTargetsFromActions(actions)
// Send mail to each target about the reescalation
for _, target := range targets {
msg := &mail.Message{
From: reescalatedBy,
To: target,
Subject: fmt.Sprintf("[%s→%s] Re-escalated: %s", strings.ToUpper(result.OldSeverity), strings.ToUpper(result.NewSeverity), result.Title),
Body: formatReescalationMailBody(result, reescalatedBy),
Type: mail.TypeTask,
}
// Set priority based on new severity
switch result.NewSeverity {
case config.SeverityCritical:
msg.Priority = mail.PriorityUrgent
case config.SeverityHigh:
msg.Priority = mail.PriorityHigh
case config.SeverityMedium:
msg.Priority = mail.PriorityNormal
default:
msg.Priority = mail.PriorityLow
}
if err := router.Send(msg); err != nil {
style.PrintWarning("failed to send reescalation to %s: %v", target, err)
}
}
// Log to activity feed
_ = events.LogFeed(events.TypeEscalationSent, reescalatedBy, map[string]interface{}{
"escalation_id": result.ID,
"reescalated": true,
"old_severity": result.OldSeverity,
"new_severity": result.NewSeverity,
"reescalation_num": result.ReescalationNum,
"targets": strings.Join(targets, ","),
})
}
}
// Output results
if escalateStaleJSON { if escalateStaleJSON {
out, _ := json.MarshalIndent(stale, "", " ") out, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(out)) fmt.Println(string(out))
return nil return nil
} }
if len(stale) == 0 { reescalated := 0
fmt.Printf("No stale escalations (threshold: %s)\n", threshold) skipped := 0
for _, r := range results {
if r.Skipped {
skipped++
} else {
reescalated++
}
}
if reescalated == 0 && skipped > 0 {
fmt.Printf("No escalations re-escalated (%d at max level)\n", skipped)
return nil return nil
} }
fmt.Printf("Stale escalations (%d, threshold: %s):\n\n", len(stale), threshold) fmt.Printf("🔄 Re-escalated %d stale escalations:\n\n", reescalated)
for _, issue := range stale { for _, result := range results {
fields := beads.ParseEscalationFields(issue.Description) if result.Skipped {
emoji := severityEmoji(fields.Severity) continue
}
emoji := severityEmoji(result.NewSeverity)
fmt.Printf(" %s %s: %s → %s (reescalation %d)\n",
emoji, result.ID, result.OldSeverity, result.NewSeverity, result.ReescalationNum)
}
fmt.Printf(" %s %s %s\n", emoji, issue.ID, issue.Title) if skipped > 0 {
fmt.Printf(" Severity: %s | From: %s | %s\n", fmt.Printf("\n (%d skipped - at max level)\n", skipped)
fields.Severity, fields.EscalatedBy, formatRelativeTime(issue.CreatedAt))
fmt.Println()
} }
return nil return nil
} }
func getNextSeverity(severity string) string {
switch severity {
case "low":
return "medium"
case "medium":
return "high"
case "high":
return "critical"
default:
return "critical"
}
}
func formatReescalationMailBody(result *beads.ReescalationResult, reescalatedBy string) string {
var lines []string
lines = append(lines, fmt.Sprintf("Escalation ID: %s", result.ID))
lines = append(lines, fmt.Sprintf("Severity bumped: %s → %s", result.OldSeverity, result.NewSeverity))
lines = append(lines, fmt.Sprintf("Reescalation #%d", result.ReescalationNum))
lines = append(lines, fmt.Sprintf("Reescalated by: %s", reescalatedBy))
lines = append(lines, "")
lines = append(lines, "This escalation was not acknowledged within the stale threshold and has been automatically re-escalated to a higher severity.")
lines = append(lines, "")
lines = append(lines, "---")
lines = append(lines, "To acknowledge: gt escalate ack "+result.ID)
lines = append(lines, "To close: gt escalate close "+result.ID+" --reason \"resolution\"")
return strings.Join(lines, "\n")
}
func runEscalateShow(cmd *cobra.Command, args []string) error { func runEscalateShow(cmd *cobra.Command, args []string) error {
escalationID := args[0] escalationID := args[0]
@@ -370,6 +522,59 @@ func runEscalateShow(cmd *cobra.Command, args []string) error {
// Helper functions // Helper functions
// extractMailTargetsFromActions extracts mail targets from action strings.
// Action format: "mail:target" returns "target"
// E.g., ["bead", "mail:mayor", "email:human"] returns ["mayor"]
func extractMailTargetsFromActions(actions []string) []string {
var targets []string
for _, action := range actions {
if strings.HasPrefix(action, "mail:") {
target := strings.TrimPrefix(action, "mail:")
if target != "" {
targets = append(targets, target)
}
}
}
return targets
}
// executeExternalActions processes external notification actions (email:, sms:, slack).
// For now, this logs warnings if contacts aren't configured - actual sending is future work.
func executeExternalActions(actions []string, cfg *config.EscalationConfig, beadID, severity, description string) {
for _, action := range actions {
switch {
case strings.HasPrefix(action, "email:"):
if cfg.Contacts.HumanEmail == "" {
style.PrintWarning("email action '%s' skipped: contacts.human_email not configured in settings/escalation.json", action)
} else {
// TODO: Implement actual email sending
fmt.Printf(" 📧 Would send email to %s (not yet implemented)\n", cfg.Contacts.HumanEmail)
}
case strings.HasPrefix(action, "sms:"):
if cfg.Contacts.HumanSMS == "" {
style.PrintWarning("sms action '%s' skipped: contacts.human_sms not configured in settings/escalation.json", action)
} else {
// TODO: Implement actual SMS sending
fmt.Printf(" 📱 Would send SMS to %s (not yet implemented)\n", cfg.Contacts.HumanSMS)
}
case action == "slack":
if cfg.Contacts.SlackWebhook == "" {
style.PrintWarning("slack action skipped: contacts.slack_webhook not configured in settings/escalation.json")
} else {
// TODO: Implement actual Slack webhook posting
fmt.Printf(" 💬 Would post to Slack (not yet implemented)\n")
}
case action == "log":
// Log action always succeeds - writes to escalation log file
// TODO: Implement actual log file writing
fmt.Printf(" 📝 Logged to escalation log\n")
}
}
}
func formatEscalationMailBody(beadID, severity, reason, from, related string) string { func formatEscalationMailBody(beadID, severity, reason, from, related string) string {
var lines []string var lines []string
lines = append(lines, fmt.Sprintf("Escalation ID: %s", beadID)) lines = append(lines, fmt.Sprintf("Escalation ID: %s", beadID))
@@ -397,7 +602,7 @@ func severityEmoji(severity string) string {
return "🚨" return "🚨"
case config.SeverityHigh: case config.SeverityHigh:
return "⚠️" return "⚠️"
case config.SeverityNormal: case config.SeverityMedium:
return "📢" return "📢"
case config.SeverityLow: case config.SeverityLow:
return "" return ""

View File

@@ -268,6 +268,14 @@ func runInstall(cmd *cobra.Command, args []string) error {
} }
} }
// Create default escalation config in settings/escalation.json
escalationPath := config.EscalationConfigPath(absPath)
if err := config.SaveEscalationConfig(escalationPath, config.NewEscalationConfig()); err != nil {
fmt.Printf(" %s Could not create escalation config: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Created settings/escalation.json\n")
}
// Provision town-level slash commands (.claude/commands/) // Provision town-level slash commands (.claude/commands/)
// All agents inherit these via Claude's directory traversal - no per-workspace copies needed. // All agents inherit these via Claude's directory traversal - no per-workspace copies needed.
if err := templates.ProvisionCommands(absPath); err != nil { if err := templates.ProvisionCommands(absPath); err != nil {

View File

@@ -1364,7 +1364,7 @@ func GetRigPrefix(townRoot, rigName string) string {
// EscalationConfigPath returns the standard path for escalation config in a town. // EscalationConfigPath returns the standard path for escalation config in a town.
func EscalationConfigPath(townRoot string) string { func EscalationConfigPath(townRoot string) string {
return filepath.Join(townRoot, "config", "escalation.json") return filepath.Join(townRoot, "settings", "escalation.json")
} }
// LoadEscalationConfig loads and validates an escalation configuration file. // LoadEscalationConfig loads and validates an escalation configuration file.
@@ -1440,48 +1440,53 @@ func validateEscalationConfig(c *EscalationConfig) error {
} }
// Initialize nil maps // Initialize nil maps
if c.SeverityRoutes == nil { if c.Routes == nil {
c.SeverityRoutes = make(map[string]EscalationRoute) c.Routes = make(map[string][]string)
} }
// Validate severity route keys // Validate severity route keys
validSeverities := map[string]bool{ for severity := range c.Routes {
SeverityCritical: true, if !IsValidSeverity(severity) {
SeverityHigh: true, return fmt.Errorf("%w: unknown severity '%s' (valid: low, medium, high, critical)", ErrMissingField, severity)
SeverityNormal: true,
SeverityLow: true,
}
for severity := range c.SeverityRoutes {
if !validSeverities[severity] {
return fmt.Errorf("%w: unknown severity '%s' (valid: critical, high, normal, low)", ErrMissingField, severity)
} }
} }
// Validate max_reescalations is non-negative
if c.MaxReescalations < 0 {
return fmt.Errorf("%w: max_reescalations must be non-negative", ErrMissingField)
}
return nil return nil
} }
// GetStaleThreshold returns the stale threshold as a time.Duration. // GetStaleThreshold returns the stale threshold as a time.Duration.
// Returns 1 hour if not configured or invalid. // Returns 4 hours if not configured or invalid.
func (c *EscalationConfig) GetStaleThreshold() time.Duration { func (c *EscalationConfig) GetStaleThreshold() time.Duration {
if c.StaleThreshold == "" { if c.StaleThreshold == "" {
return time.Hour return 4 * time.Hour
} }
d, err := time.ParseDuration(c.StaleThreshold) d, err := time.ParseDuration(c.StaleThreshold)
if err != nil { if err != nil {
return time.Hour return 4 * time.Hour
} }
return d return d
} }
// GetRouteForSeverity returns the escalation route for a given severity. // GetRouteForSeverity returns the escalation route actions for a given severity.
// Falls back to DefaultTarget if no specific route is configured. // Falls back to ["bead", "mail:mayor"] if no specific route is configured.
func (c *EscalationConfig) GetRouteForSeverity(severity string) EscalationRoute { func (c *EscalationConfig) GetRouteForSeverity(severity string) []string {
if route, ok := c.SeverityRoutes[severity]; ok { if route, ok := c.Routes[severity]; ok {
return route return route
} }
// Fallback to default target // Fallback to default route
return EscalationRoute{ return []string{"bead", "mail:mayor"}
Targets: []string{c.DefaultTarget}, }
UseExternal: false,
} // GetMaxReescalations returns the maximum number of re-escalations allowed.
// Returns 2 if not configured.
func (c *EscalationConfig) GetMaxReescalations() int {
if c.MaxReescalations <= 0 {
return 2
}
return c.MaxReescalations
} }

View File

@@ -1954,3 +1954,370 @@ func TestRoleAgentsRoundTrip(t *testing.T) {
} }
}) })
} }
// Escalation config tests
func TestEscalationConfigRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "settings", "escalation.json")
original := &EscalationConfig{
Type: "escalation",
Version: CurrentEscalationVersion,
Routes: map[string][]string{
SeverityLow: {"bead"},
SeverityMedium: {"bead", "mail:mayor"},
SeverityHigh: {"bead", "mail:mayor", "email:human"},
SeverityCritical: {"bead", "mail:mayor", "email:human", "sms:human"},
},
Contacts: EscalationContacts{
HumanEmail: "test@example.com",
HumanSMS: "+15551234567",
},
StaleThreshold: "2h",
MaxReescalations: 3,
}
if err := SaveEscalationConfig(path, original); err != nil {
t.Fatalf("SaveEscalationConfig: %v", err)
}
loaded, err := LoadEscalationConfig(path)
if err != nil {
t.Fatalf("LoadEscalationConfig: %v", err)
}
if loaded.Type != original.Type {
t.Errorf("Type = %q, want %q", loaded.Type, original.Type)
}
if loaded.Version != original.Version {
t.Errorf("Version = %d, want %d", loaded.Version, original.Version)
}
if loaded.StaleThreshold != original.StaleThreshold {
t.Errorf("StaleThreshold = %q, want %q", loaded.StaleThreshold, original.StaleThreshold)
}
if loaded.MaxReescalations != original.MaxReescalations {
t.Errorf("MaxReescalations = %d, want %d", loaded.MaxReescalations, original.MaxReescalations)
}
if loaded.Contacts.HumanEmail != original.Contacts.HumanEmail {
t.Errorf("Contacts.HumanEmail = %q, want %q", loaded.Contacts.HumanEmail, original.Contacts.HumanEmail)
}
if loaded.Contacts.HumanSMS != original.Contacts.HumanSMS {
t.Errorf("Contacts.HumanSMS = %q, want %q", loaded.Contacts.HumanSMS, original.Contacts.HumanSMS)
}
// Check routes
for severity, actions := range original.Routes {
loadedActions := loaded.Routes[severity]
if len(loadedActions) != len(actions) {
t.Errorf("Routes[%s] len = %d, want %d", severity, len(loadedActions), len(actions))
continue
}
for i, action := range actions {
if loadedActions[i] != action {
t.Errorf("Routes[%s][%d] = %q, want %q", severity, i, loadedActions[i], action)
}
}
}
}
func TestEscalationConfigDefaults(t *testing.T) {
t.Parallel()
cfg := NewEscalationConfig()
if cfg.Type != "escalation" {
t.Errorf("Type = %q, want %q", cfg.Type, "escalation")
}
if cfg.Version != CurrentEscalationVersion {
t.Errorf("Version = %d, want %d", cfg.Version, CurrentEscalationVersion)
}
if cfg.StaleThreshold != "4h" {
t.Errorf("StaleThreshold = %q, want %q", cfg.StaleThreshold, "4h")
}
if cfg.MaxReescalations != 2 {
t.Errorf("MaxReescalations = %d, want %d", cfg.MaxReescalations, 2)
}
// Check default routes
if len(cfg.Routes) != 4 {
t.Errorf("Routes count = %d, want 4", len(cfg.Routes))
}
if len(cfg.Routes[SeverityLow]) != 1 || cfg.Routes[SeverityLow][0] != "bead" {
t.Errorf("Routes[low] = %v, want [bead]", cfg.Routes[SeverityLow])
}
if len(cfg.Routes[SeverityCritical]) != 4 {
t.Errorf("Routes[critical] len = %d, want 4", len(cfg.Routes[SeverityCritical]))
}
}
func TestEscalationConfigValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *EscalationConfig
wantErr bool
errMsg string
}{
{
name: "valid config",
config: &EscalationConfig{
Type: "escalation",
Version: 1,
Routes: map[string][]string{
SeverityLow: {"bead"},
},
},
wantErr: false,
},
{
name: "invalid type",
config: &EscalationConfig{
Type: "wrong-type",
Version: 1,
},
wantErr: true,
errMsg: "invalid config type",
},
{
name: "unsupported version",
config: &EscalationConfig{
Type: "escalation",
Version: 999,
},
wantErr: true,
errMsg: "unsupported config version",
},
{
name: "invalid stale threshold",
config: &EscalationConfig{
Type: "escalation",
Version: 1,
StaleThreshold: "not-a-duration",
},
wantErr: true,
errMsg: "invalid stale_threshold",
},
{
name: "invalid severity key",
config: &EscalationConfig{
Type: "escalation",
Version: 1,
Routes: map[string][]string{
"invalid-severity": {"bead"},
},
},
wantErr: true,
errMsg: "unknown severity",
},
{
name: "negative max reescalations",
config: &EscalationConfig{
Type: "escalation",
Version: 1,
MaxReescalations: -1,
},
wantErr: true,
errMsg: "max_reescalations must be non-negative",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateEscalationConfig(tt.config)
if tt.wantErr {
if err == nil {
t.Errorf("validateEscalationConfig() expected error containing %q, got nil", tt.errMsg)
} else if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validateEscalationConfig() error = %v, want error containing %q", err, tt.errMsg)
}
} else {
if err != nil {
t.Errorf("validateEscalationConfig() unexpected error: %v", err)
}
}
})
}
}
func TestEscalationConfigGetStaleThreshold(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *EscalationConfig
expected time.Duration
}{
{
name: "default when empty",
config: &EscalationConfig{},
expected: 4 * time.Hour,
},
{
name: "2 hours",
config: &EscalationConfig{
StaleThreshold: "2h",
},
expected: 2 * time.Hour,
},
{
name: "30 minutes",
config: &EscalationConfig{
StaleThreshold: "30m",
},
expected: 30 * time.Minute,
},
{
name: "invalid duration falls back to default",
config: &EscalationConfig{
StaleThreshold: "invalid",
},
expected: 4 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.config.GetStaleThreshold()
if got != tt.expected {
t.Errorf("GetStaleThreshold() = %v, want %v", got, tt.expected)
}
})
}
}
func TestEscalationConfigGetRouteForSeverity(t *testing.T) {
t.Parallel()
cfg := &EscalationConfig{
Routes: map[string][]string{
SeverityLow: {"bead"},
SeverityMedium: {"bead", "mail:mayor"},
},
}
tests := []struct {
severity string
expected []string
}{
{SeverityLow, []string{"bead"}},
{SeverityMedium, []string{"bead", "mail:mayor"}},
{SeverityHigh, []string{"bead", "mail:mayor"}}, // fallback for missing
{SeverityCritical, []string{"bead", "mail:mayor"}}, // fallback for missing
}
for _, tt := range tests {
t.Run(tt.severity, func(t *testing.T) {
got := cfg.GetRouteForSeverity(tt.severity)
if len(got) != len(tt.expected) {
t.Errorf("GetRouteForSeverity(%s) len = %d, want %d", tt.severity, len(got), len(tt.expected))
return
}
for i, action := range tt.expected {
if got[i] != action {
t.Errorf("GetRouteForSeverity(%s)[%d] = %q, want %q", tt.severity, i, got[i], action)
}
}
})
}
}
func TestEscalationConfigGetMaxReescalations(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *EscalationConfig
expected int
}{
{
name: "default when zero",
config: &EscalationConfig{},
expected: 2,
},
{
name: "custom value",
config: &EscalationConfig{
MaxReescalations: 5,
},
expected: 5,
},
{
name: "default when negative (should not happen after validation)",
config: &EscalationConfig{
MaxReescalations: -1,
},
expected: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.config.GetMaxReescalations()
if got != tt.expected {
t.Errorf("GetMaxReescalations() = %d, want %d", got, tt.expected)
}
})
}
}
func TestLoadOrCreateEscalationConfig(t *testing.T) {
t.Parallel()
t.Run("creates default when not found", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings", "escalation.json")
cfg, err := LoadOrCreateEscalationConfig(path)
if err != nil {
t.Fatalf("LoadOrCreateEscalationConfig: %v", err)
}
if cfg.Type != "escalation" {
t.Errorf("Type = %q, want %q", cfg.Type, "escalation")
}
if len(cfg.Routes) != 4 {
t.Errorf("Routes count = %d, want 4", len(cfg.Routes))
}
})
t.Run("loads existing config", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings", "escalation.json")
// Create a config first
original := &EscalationConfig{
Type: "escalation",
Version: 1,
StaleThreshold: "1h",
Routes: map[string][]string{
SeverityLow: {"bead"},
},
}
if err := SaveEscalationConfig(path, original); err != nil {
t.Fatalf("SaveEscalationConfig: %v", err)
}
// Load it
cfg, err := LoadOrCreateEscalationConfig(path)
if err != nil {
t.Fatalf("LoadOrCreateEscalationConfig: %v", err)
}
if cfg.StaleThreshold != "1h" {
t.Errorf("StaleThreshold = %q, want %q", cfg.StaleThreshold, "1h")
}
})
}
func TestEscalationConfigPath(t *testing.T) {
t.Parallel()
path := EscalationConfigPath("/home/user/gt")
expected := "/home/user/gt/settings/escalation.json"
if path != expected {
t.Errorf("EscalationConfigPath = %q, want %q", path, expected)
}
}

View File

@@ -789,70 +789,42 @@ func NewMessagingConfig() *MessagingConfig {
} }
} }
// EscalationConfig represents the escalation system configuration (config/escalation.json). // EscalationConfig represents escalation routing configuration (settings/escalation.json).
// This defines severity-based routing for escalations to different channels. // This defines severity-based routing for escalations to different channels.
type EscalationConfig struct { type EscalationConfig struct {
Type string `json:"type"` // "escalation" Type string `json:"type"` // "escalation"
Version int `json:"version"` // schema version Version int `json:"version"` // schema version
// Enabled controls whether the escalation system is active. // Routes maps severity levels to action lists.
Enabled bool `json:"enabled"` // Actions are executed in order for each escalation.
// Action formats:
// - "bead" → Create escalation bead (always first, implicit)
// - "mail:<target>" → Send gt mail to target (e.g., "mail:mayor")
// - "email:human" → Send email to contacts.human_email
// - "sms:human" → Send SMS to contacts.human_sms
// - "slack" → Post to contacts.slack_webhook
// - "log" → Write to escalation log file
Routes map[string][]string `json:"routes"`
// DefaultTarget is the address to send escalations when no severity-specific target is set. // Contacts contains contact information for external notification actions.
// Example: "mayor/" Contacts EscalationContacts `json:"contacts"`
DefaultTarget string `json:"default_target,omitempty"`
// SeverityRoutes maps severity levels to notification targets. // StaleThreshold is how long before an unacknowledged escalation
// Keys: "critical", "high", "normal", "low" // is considered stale and gets re-escalated.
// Values: EscalationRoute with target addresses and optional external channels // Format: Go duration string (e.g., "4h", "30m", "24h")
SeverityRoutes map[string]EscalationRoute `json:"severity_routes,omitempty"` // Default: "4h"
// StaleThreshold is the duration after which an unacknowledged escalation is considered stale.
// Format: Go duration string (e.g., "1h", "30m", "24h")
// Default: "1h"
StaleThreshold string `json:"stale_threshold,omitempty"` StaleThreshold string `json:"stale_threshold,omitempty"`
// ExternalChannels configures optional external notification channels (email, SMS, etc.) // MaxReescalations limits how many times an escalation can be
ExternalChannels *ExternalChannelsConfig `json:"external_channels,omitempty"` // re-escalated. Default: 2 (low→medium→high, then stops)
MaxReescalations int `json:"max_reescalations,omitempty"`
} }
// EscalationRoute defines where escalations of a given severity are routed. // EscalationContacts contains contact information for external notification channels.
type EscalationRoute struct { type EscalationContacts struct {
// Targets are the internal addresses to notify (e.g., "mayor/", "gastown/witness") HumanEmail string `json:"human_email,omitempty"` // email address for email:human action
Targets []string `json:"targets"` HumanSMS string `json:"human_sms,omitempty"` // phone number for sms:human action
SlackWebhook string `json:"slack_webhook,omitempty"` // webhook URL for slack action
// UseExternal enables external channel notifications for this severity.
// If true, checks ExternalChannels config for enabled channels.
UseExternal bool `json:"use_external,omitempty"`
// Channels overrides which external channels to use for this severity.
// If empty and UseExternal is true, uses all enabled channels.
// Example: ["email"] to only use email for high severity
Channels []string `json:"channels,omitempty"`
}
// ExternalChannelsConfig configures external notification channels.
type ExternalChannelsConfig struct {
// Email configuration for email notifications
Email *EmailChannelConfig `json:"email,omitempty"`
// SMS configuration for SMS notifications (future)
SMS *SMSChannelConfig `json:"sms,omitempty"`
}
// EmailChannelConfig configures email notifications.
type EmailChannelConfig struct {
Enabled bool `json:"enabled"`
Recipients []string `json:"recipients,omitempty"` // email addresses
SMTPServer string `json:"smtp_server,omitempty"`
FromAddr string `json:"from_addr,omitempty"`
}
// SMSChannelConfig configures SMS notifications (placeholder for future).
type SMSChannelConfig struct {
Enabled bool `json:"enabled"`
Recipients []string `json:"recipients,omitempty"` // phone numbers
Provider string `json:"provider,omitempty"` // twilio, etc.
} }
// CurrentEscalationVersion is the current schema version for EscalationConfig. // CurrentEscalationVersion is the current schema version for EscalationConfig.
@@ -862,35 +834,53 @@ const CurrentEscalationVersion = 1
const ( const (
SeverityCritical = "critical" // P0: immediate attention required SeverityCritical = "critical" // P0: immediate attention required
SeverityHigh = "high" // P1: urgent, needs attention soon SeverityHigh = "high" // P1: urgent, needs attention soon
SeverityNormal = "normal" // P2: standard escalation (default) SeverityMedium = "medium" // P2: standard escalation (default)
SeverityLow = "low" // P3: informational, can wait SeverityLow = "low" // P3: informational, can wait
) )
// ValidSeverities returns the list of valid severity levels in order of priority.
func ValidSeverities() []string {
return []string{SeverityLow, SeverityMedium, SeverityHigh, SeverityCritical}
}
// IsValidSeverity checks if a severity level is valid.
func IsValidSeverity(severity string) bool {
switch severity {
case SeverityLow, SeverityMedium, SeverityHigh, SeverityCritical:
return true
default:
return false
}
}
// NextSeverity returns the next higher severity level for re-escalation.
// Returns the same level if already at critical.
func NextSeverity(severity string) string {
switch severity {
case SeverityLow:
return SeverityMedium
case SeverityMedium:
return SeverityHigh
case SeverityHigh:
return SeverityCritical
default:
return SeverityCritical
}
}
// NewEscalationConfig creates a new EscalationConfig with sensible defaults. // NewEscalationConfig creates a new EscalationConfig with sensible defaults.
func NewEscalationConfig() *EscalationConfig { func NewEscalationConfig() *EscalationConfig {
return &EscalationConfig{ return &EscalationConfig{
Type: "escalation", Type: "escalation",
Version: CurrentEscalationVersion, Version: CurrentEscalationVersion,
Enabled: true, Routes: map[string][]string{
DefaultTarget: "mayor/", SeverityLow: {"bead"},
StaleThreshold: "1h", SeverityMedium: {"bead", "mail:mayor"},
SeverityRoutes: map[string]EscalationRoute{ SeverityHigh: {"bead", "mail:mayor", "email:human"},
SeverityCritical: { SeverityCritical: {"bead", "mail:mayor", "email:human", "sms:human"},
Targets: []string{"mayor/"},
UseExternal: true, // Critical should notify externally by default
},
SeverityHigh: {
Targets: []string{"mayor/"},
UseExternal: false,
},
SeverityNormal: {
Targets: []string{"mayor/"},
UseExternal: false,
},
SeverityLow: {
Targets: []string{"mayor/"},
UseExternal: false,
},
}, },
Contacts: EscalationContacts{},
StaleThreshold: "4h",
MaxReescalations: 2,
} }
} }