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:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -13,15 +14,20 @@ import (
|
||||
// EscalationFields holds structured fields for escalation beads.
|
||||
// These are stored as "key: value" lines in the description.
|
||||
type EscalationFields struct {
|
||||
Severity string // critical, high, normal, low
|
||||
Reason string // Why this was escalated
|
||||
EscalatedBy string // Agent address that escalated (e.g., "gastown/Toast")
|
||||
EscalatedAt string // ISO 8601 timestamp
|
||||
AckedBy string // Agent that acknowledged (empty if not acked)
|
||||
AckedAt string // When acknowledged (empty if not acked)
|
||||
ClosedBy string // Agent that closed (empty if not closed)
|
||||
ClosedReason string // Resolution reason (empty if not closed)
|
||||
RelatedBead string // Optional: related bead ID (task, bug, etc.)
|
||||
Severity string // critical, high, medium, low
|
||||
Reason string // Why this was escalated
|
||||
Source string // Source identifier (e.g., plugin:rebuild-gt, patrol:deacon)
|
||||
EscalatedBy string // Agent address that escalated (e.g., "gastown/Toast")
|
||||
EscalatedAt string // ISO 8601 timestamp
|
||||
AckedBy string // Agent that acknowledged (empty if not acked)
|
||||
AckedAt string // When acknowledged (empty if not acked)
|
||||
ClosedBy string // Agent that closed (empty if not closed)
|
||||
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.
|
||||
@@ -42,6 +48,11 @@ func FormatEscalationDescription(title string, fields *EscalationFields) string
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fmt.Sprintf("severity: %s", fields.Severity))
|
||||
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_at: %s", fields.EscalatedAt))
|
||||
|
||||
@@ -75,6 +86,24 @@ func FormatEscalationDescription(title string, fields *EscalationFields) string
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -104,6 +133,8 @@ func ParseEscalationFields(description string) *EscalationFields {
|
||||
fields.Severity = value
|
||||
case "reason":
|
||||
fields.Reason = value
|
||||
case "source":
|
||||
fields.Source = value
|
||||
case "escalated_by":
|
||||
fields.EscalatedBy = value
|
||||
case "escalated_at":
|
||||
@@ -118,6 +149,16 @@ func ParseEscalationFields(description string) *EscalationFields {
|
||||
fields.ClosedReason = value
|
||||
case "related_bead":
|
||||
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
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
var (
|
||||
escalateSeverity string
|
||||
escalateReason string
|
||||
escalateSource string
|
||||
escalateRelatedBead string
|
||||
escalateJSON bool
|
||||
escalateListJSON bool
|
||||
@@ -30,30 +31,31 @@ human or mayor attention. Escalations are tracked as beads with gt:escalation la
|
||||
SEVERITY LEVELS:
|
||||
critical (P0) Immediate attention required
|
||||
high (P1) Urgent, needs attention soon
|
||||
normal (P2) Standard escalation (default)
|
||||
medium (P2) Standard escalation (default)
|
||||
low (P3) Informational, can wait
|
||||
|
||||
WORKFLOW:
|
||||
1. Agent encounters blocking issue
|
||||
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>
|
||||
5. After resolution: gt escalate close <id> --reason "fixed"
|
||||
|
||||
CONFIGURATION:
|
||||
Routing is configured in ~/gt/config/escalation.json:
|
||||
- severity_routes: Map severity to notification targets
|
||||
- external_channels: Optional email/SMS for critical issues
|
||||
- stale_threshold: When unacked escalations are flagged
|
||||
Routing is configured in ~/gt/settings/escalation.json:
|
||||
- routes: Map severity to action lists (bead, mail:mayor, email:human, sms:human)
|
||||
- contacts: Human email/SMS for external notifications
|
||||
- stale_threshold: When unacked escalations are re-escalated (default: 4h)
|
||||
- max_reescalations: How many times to bump severity (default: 2)
|
||||
|
||||
Examples:
|
||||
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 list # Show open escalations
|
||||
gt escalate ack hq-abc123 # Acknowledge
|
||||
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{
|
||||
@@ -101,15 +103,23 @@ Examples:
|
||||
|
||||
var escalateStaleCmd = &cobra.Command{
|
||||
Use: "stale",
|
||||
Short: "Show stale unacknowledged escalations",
|
||||
Long: `Show escalations that haven't been acknowledged within the threshold.
|
||||
Short: "Re-escalate stale unacknowledged escalations",
|
||||
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).
|
||||
Useful for patrol agents to detect escalations that need attention.
|
||||
When run without --dry-run, this command:
|
||||
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:
|
||||
gt escalate stale # Show stale escalations
|
||||
gt escalate stale --json # JSON output`,
|
||||
gt escalate stale # Re-escalate stale escalations
|
||||
gt escalate stale --dry-run # Show what would be done
|
||||
gt escalate stale --json # JSON output of results`,
|
||||
RunE: runEscalateStale,
|
||||
}
|
||||
|
||||
@@ -127,8 +137,9 @@ Examples:
|
||||
|
||||
func init() {
|
||||
// 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().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().BoolVar(&escalateJSON, "json", false, "Output as JSON")
|
||||
escalateCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false, "Show what would be done without executing")
|
||||
@@ -143,6 +154,7 @@ func init() {
|
||||
|
||||
// Stale subcommand flags
|
||||
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
|
||||
escalateShowCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON")
|
||||
|
||||
@@ -26,14 +26,8 @@ func runEscalate(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Validate severity
|
||||
severity := strings.ToLower(escalateSeverity)
|
||||
validSeverities := map[string]bool{
|
||||
config.SeverityCritical: true,
|
||||
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)
|
||||
if !config.IsValidSeverity(severity) {
|
||||
return fmt.Errorf("invalid severity '%s': must be critical, high, medium, or low", escalateSeverity)
|
||||
}
|
||||
|
||||
// Find workspace
|
||||
@@ -48,10 +42,6 @@ func runEscalate(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("loading escalation config: %w", err)
|
||||
}
|
||||
|
||||
if !escalationConfig.Enabled {
|
||||
return fmt.Errorf("escalation system is disabled in config")
|
||||
}
|
||||
|
||||
// Detect agent identity
|
||||
agentID := detectSender()
|
||||
if agentID == "" {
|
||||
@@ -60,17 +50,19 @@ func runEscalate(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Dry run mode
|
||||
if escalateDryRun {
|
||||
route := escalationConfig.GetRouteForSeverity(severity)
|
||||
actions := escalationConfig.GetRouteForSeverity(severity)
|
||||
targets := extractMailTargetsFromActions(actions)
|
||||
fmt.Printf("Would create escalation:\n")
|
||||
fmt.Printf(" Severity: %s\n", severity)
|
||||
fmt.Printf(" Description: %s\n", description)
|
||||
if escalateReason != "" {
|
||||
fmt.Printf(" Reason: %s\n", escalateReason)
|
||||
}
|
||||
fmt.Printf(" Targets: %s\n", strings.Join(route.Targets, ", "))
|
||||
if route.UseExternal {
|
||||
fmt.Printf(" External: enabled\n")
|
||||
if escalateSource != "" {
|
||||
fmt.Printf(" Source: %s\n", escalateSource)
|
||||
}
|
||||
fmt.Printf(" Actions: %s\n", strings.Join(actions, ", "))
|
||||
fmt.Printf(" Mail targets: %s\n", strings.Join(targets, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -79,6 +71,7 @@ func runEscalate(cmd *cobra.Command, args []string) error {
|
||||
fields := &beads.EscalationFields{
|
||||
Severity: severity,
|
||||
Reason: escalateReason,
|
||||
Source: escalateSource,
|
||||
EscalatedBy: agentID,
|
||||
EscalatedAt: time.Now().Format(time.RFC3339),
|
||||
RelatedBead: escalateRelatedBead,
|
||||
@@ -89,12 +82,13 @@ func runEscalate(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("creating escalation bead: %w", err)
|
||||
}
|
||||
|
||||
// Get routing for this severity
|
||||
route := escalationConfig.GetRouteForSeverity(severity)
|
||||
// Get routing actions for this 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)
|
||||
for _, target := range route.Targets {
|
||||
for _, target := range targets {
|
||||
msg := &mail.Message{
|
||||
From: agentID,
|
||||
To: target,
|
||||
@@ -109,7 +103,7 @@ func runEscalate(cmd *cobra.Command, args []string) error {
|
||||
msg.Priority = mail.PriorityUrgent
|
||||
case config.SeverityHigh:
|
||||
msg.Priority = mail.PriorityHigh
|
||||
case config.SeverityNormal:
|
||||
case config.SeverityMedium:
|
||||
msg.Priority = mail.PriorityNormal
|
||||
default:
|
||||
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
|
||||
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["actions"] = strings.Join(actions, ",")
|
||||
if escalateSource != "" {
|
||||
payload["source"] = escalateSource
|
||||
}
|
||||
_ = events.LogFeed(events.TypeEscalationSent, agentID, payload)
|
||||
|
||||
// Output
|
||||
if escalateJSON {
|
||||
out, _ := json.MarshalIndent(map[string]interface{}{
|
||||
result := map[string]interface{}{
|
||||
"id": issue.ID,
|
||||
"severity": severity,
|
||||
"targets": route.Targets,
|
||||
}, "", " ")
|
||||
"actions": actions,
|
||||
"targets": targets,
|
||||
}
|
||||
if escalateSource != "" {
|
||||
result["source"] = escalateSource
|
||||
}
|
||||
out, _ := json.MarshalIndent(result, "", " ")
|
||||
fmt.Println(string(out))
|
||||
} else {
|
||||
emoji := severityEmoji(severity)
|
||||
fmt.Printf("%s Escalation created: %s\n", emoji, issue.ID)
|
||||
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
|
||||
@@ -267,13 +276,14 @@ func runEscalateStale(cmd *cobra.Command, args []string) error {
|
||||
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))
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading escalation config: %w", err)
|
||||
}
|
||||
|
||||
threshold := escalationConfig.GetStaleThreshold()
|
||||
maxReescalations := escalationConfig.GetMaxReescalations()
|
||||
|
||||
bd := beads.New(beads.ResolveBeadsDir(townRoot))
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
out, _ := json.MarshalIndent(stale, "", " ")
|
||||
out, _ := json.MarshalIndent(results, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(stale) == 0 {
|
||||
fmt.Printf("No stale escalations (threshold: %s)\n", threshold)
|
||||
reescalated := 0
|
||||
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
|
||||
}
|
||||
|
||||
fmt.Printf("Stale escalations (%d, threshold: %s):\n\n", len(stale), threshold)
|
||||
for _, issue := range stale {
|
||||
fields := beads.ParseEscalationFields(issue.Description)
|
||||
emoji := severityEmoji(fields.Severity)
|
||||
fmt.Printf("🔄 Re-escalated %d stale escalations:\n\n", reescalated)
|
||||
for _, result := range results {
|
||||
if result.Skipped {
|
||||
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)
|
||||
fmt.Printf(" Severity: %s | From: %s | %s\n",
|
||||
fields.Severity, fields.EscalatedBy, formatRelativeTime(issue.CreatedAt))
|
||||
fmt.Println()
|
||||
if skipped > 0 {
|
||||
fmt.Printf("\n (%d skipped - at max level)\n", skipped)
|
||||
}
|
||||
|
||||
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 {
|
||||
escalationID := args[0]
|
||||
|
||||
@@ -370,6 +522,59 @@ func runEscalateShow(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// 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 {
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Escalation ID: %s", beadID))
|
||||
@@ -397,7 +602,7 @@ func severityEmoji(severity string) string {
|
||||
return "🚨"
|
||||
case config.SeverityHigh:
|
||||
return "⚠️"
|
||||
case config.SeverityNormal:
|
||||
case config.SeverityMedium:
|
||||
return "📢"
|
||||
case config.SeverityLow:
|
||||
return "ℹ️"
|
||||
|
||||
@@ -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/)
|
||||
// All agents inherit these via Claude's directory traversal - no per-workspace copies needed.
|
||||
if err := templates.ProvisionCommands(absPath); err != nil {
|
||||
|
||||
@@ -1364,7 +1364,7 @@ func GetRigPrefix(townRoot, rigName string) string {
|
||||
|
||||
// EscalationConfigPath returns the standard path for escalation config in a town.
|
||||
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.
|
||||
@@ -1440,48 +1440,53 @@ func validateEscalationConfig(c *EscalationConfig) error {
|
||||
}
|
||||
|
||||
// Initialize nil maps
|
||||
if c.SeverityRoutes == nil {
|
||||
c.SeverityRoutes = make(map[string]EscalationRoute)
|
||||
if c.Routes == nil {
|
||||
c.Routes = make(map[string][]string)
|
||||
}
|
||||
|
||||
// Validate severity route keys
|
||||
validSeverities := map[string]bool{
|
||||
SeverityCritical: true,
|
||||
SeverityHigh: true,
|
||||
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)
|
||||
for severity := range c.Routes {
|
||||
if !IsValidSeverity(severity) {
|
||||
return fmt.Errorf("%w: unknown severity '%s' (valid: low, medium, high, critical)", 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if c.StaleThreshold == "" {
|
||||
return time.Hour
|
||||
return 4 * time.Hour
|
||||
}
|
||||
d, err := time.ParseDuration(c.StaleThreshold)
|
||||
if err != nil {
|
||||
return time.Hour
|
||||
return 4 * time.Hour
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// GetRouteForSeverity returns the escalation route for a given severity.
|
||||
// Falls back to DefaultTarget if no specific route is configured.
|
||||
func (c *EscalationConfig) GetRouteForSeverity(severity string) EscalationRoute {
|
||||
if route, ok := c.SeverityRoutes[severity]; ok {
|
||||
// GetRouteForSeverity returns the escalation route actions for a given severity.
|
||||
// Falls back to ["bead", "mail:mayor"] if no specific route is configured.
|
||||
func (c *EscalationConfig) GetRouteForSeverity(severity string) []string {
|
||||
if route, ok := c.Routes[severity]; ok {
|
||||
return route
|
||||
}
|
||||
// Fallback to default target
|
||||
return EscalationRoute{
|
||||
Targets: []string{c.DefaultTarget},
|
||||
UseExternal: false,
|
||||
}
|
||||
// Fallback to default route
|
||||
return []string{"bead", "mail:mayor"}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
type EscalationConfig struct {
|
||||
Type string `json:"type"` // "escalation"
|
||||
Version int `json:"version"` // schema version
|
||||
|
||||
// Enabled controls whether the escalation system is active.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Routes maps severity levels to action lists.
|
||||
// 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.
|
||||
// Example: "mayor/"
|
||||
DefaultTarget string `json:"default_target,omitempty"`
|
||||
// Contacts contains contact information for external notification actions.
|
||||
Contacts EscalationContacts `json:"contacts"`
|
||||
|
||||
// SeverityRoutes maps severity levels to notification targets.
|
||||
// Keys: "critical", "high", "normal", "low"
|
||||
// Values: EscalationRoute with target addresses and optional external channels
|
||||
SeverityRoutes map[string]EscalationRoute `json:"severity_routes,omitempty"`
|
||||
|
||||
// StaleThreshold is the duration after which an unacknowledged escalation is considered stale.
|
||||
// Format: Go duration string (e.g., "1h", "30m", "24h")
|
||||
// Default: "1h"
|
||||
// StaleThreshold is how long before an unacknowledged escalation
|
||||
// is considered stale and gets re-escalated.
|
||||
// Format: Go duration string (e.g., "4h", "30m", "24h")
|
||||
// Default: "4h"
|
||||
StaleThreshold string `json:"stale_threshold,omitempty"`
|
||||
|
||||
// ExternalChannels configures optional external notification channels (email, SMS, etc.)
|
||||
ExternalChannels *ExternalChannelsConfig `json:"external_channels,omitempty"`
|
||||
// MaxReescalations limits how many times an escalation can be
|
||||
// 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.
|
||||
type EscalationRoute struct {
|
||||
// Targets are the internal addresses to notify (e.g., "mayor/", "gastown/witness")
|
||||
Targets []string `json:"targets"`
|
||||
|
||||
// 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.
|
||||
// EscalationContacts contains contact information for external notification channels.
|
||||
type EscalationContacts struct {
|
||||
HumanEmail string `json:"human_email,omitempty"` // email address for email:human action
|
||||
HumanSMS string `json:"human_sms,omitempty"` // phone number for sms:human action
|
||||
SlackWebhook string `json:"slack_webhook,omitempty"` // webhook URL for slack action
|
||||
}
|
||||
|
||||
// CurrentEscalationVersion is the current schema version for EscalationConfig.
|
||||
@@ -862,35 +834,53 @@ const CurrentEscalationVersion = 1
|
||||
const (
|
||||
SeverityCritical = "critical" // P0: immediate attention required
|
||||
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
|
||||
)
|
||||
|
||||
// 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.
|
||||
func NewEscalationConfig() *EscalationConfig {
|
||||
return &EscalationConfig{
|
||||
Type: "escalation",
|
||||
Version: CurrentEscalationVersion,
|
||||
Enabled: true,
|
||||
DefaultTarget: "mayor/",
|
||||
StaleThreshold: "1h",
|
||||
SeverityRoutes: map[string]EscalationRoute{
|
||||
SeverityCritical: {
|
||||
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,
|
||||
},
|
||||
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{},
|
||||
StaleThreshold: "4h",
|
||||
MaxReescalations: 2,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user