Skip to content

Commit 0d0d276

Browse files
steveyeggeclaude
andcommitted
feat: Implement unified escalation system (gt-i9r20)
Add severity-based routing for escalations with config-driven targets. Changes: - EscalationConfig type with severity routes and external channels - beads/beads_escalation.go: Escalation bead operations (create/ack/close/list) - Refactored gt escalate command with subcommands: - list: Show open escalations - ack: Acknowledge an escalation - close: Resolve with reason - stale: Find unacknowledged escalations past threshold - show: Display escalation details - Added TypeEscalationAcked and TypeEscalationClosed event types Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ea5d72a commit 0d0d276

6 files changed

Lines changed: 1125 additions & 228 deletions

File tree

internal/beads/beads_escalation.go

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
// Package beads provides escalation bead management.
2+
package beads
3+
4+
import (
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"strings"
10+
"time"
11+
)
12+
13+
// EscalationFields holds structured fields for escalation beads.
14+
// These are stored as "key: value" lines in the description.
15+
type EscalationFields struct {
16+
Severity string // critical, high, normal, low
17+
Reason string // Why this was escalated
18+
EscalatedBy string // Agent address that escalated (e.g., "gastown/Toast")
19+
EscalatedAt string // ISO 8601 timestamp
20+
AckedBy string // Agent that acknowledged (empty if not acked)
21+
AckedAt string // When acknowledged (empty if not acked)
22+
ClosedBy string // Agent that closed (empty if not closed)
23+
ClosedReason string // Resolution reason (empty if not closed)
24+
RelatedBead string // Optional: related bead ID (task, bug, etc.)
25+
}
26+
27+
// EscalationState constants for bead status tracking.
28+
const (
29+
EscalationOpen = "open" // Unacknowledged
30+
EscalationAcked = "acked" // Acknowledged but not resolved
31+
EscalationClosed = "closed" // Resolved/closed
32+
)
33+
34+
// FormatEscalationDescription creates a description string from escalation fields.
35+
func FormatEscalationDescription(title string, fields *EscalationFields) string {
36+
if fields == nil {
37+
return title
38+
}
39+
40+
var lines []string
41+
lines = append(lines, title)
42+
lines = append(lines, "")
43+
lines = append(lines, fmt.Sprintf("severity: %s", fields.Severity))
44+
lines = append(lines, fmt.Sprintf("reason: %s", fields.Reason))
45+
lines = append(lines, fmt.Sprintf("escalated_by: %s", fields.EscalatedBy))
46+
lines = append(lines, fmt.Sprintf("escalated_at: %s", fields.EscalatedAt))
47+
48+
if fields.AckedBy != "" {
49+
lines = append(lines, fmt.Sprintf("acked_by: %s", fields.AckedBy))
50+
} else {
51+
lines = append(lines, "acked_by: null")
52+
}
53+
54+
if fields.AckedAt != "" {
55+
lines = append(lines, fmt.Sprintf("acked_at: %s", fields.AckedAt))
56+
} else {
57+
lines = append(lines, "acked_at: null")
58+
}
59+
60+
if fields.ClosedBy != "" {
61+
lines = append(lines, fmt.Sprintf("closed_by: %s", fields.ClosedBy))
62+
} else {
63+
lines = append(lines, "closed_by: null")
64+
}
65+
66+
if fields.ClosedReason != "" {
67+
lines = append(lines, fmt.Sprintf("closed_reason: %s", fields.ClosedReason))
68+
} else {
69+
lines = append(lines, "closed_reason: null")
70+
}
71+
72+
if fields.RelatedBead != "" {
73+
lines = append(lines, fmt.Sprintf("related_bead: %s", fields.RelatedBead))
74+
} else {
75+
lines = append(lines, "related_bead: null")
76+
}
77+
78+
return strings.Join(lines, "\n")
79+
}
80+
81+
// ParseEscalationFields extracts escalation fields from an issue's description.
82+
func ParseEscalationFields(description string) *EscalationFields {
83+
fields := &EscalationFields{}
84+
85+
for _, line := range strings.Split(description, "\n") {
86+
line = strings.TrimSpace(line)
87+
if line == "" {
88+
continue
89+
}
90+
91+
colonIdx := strings.Index(line, ":")
92+
if colonIdx == -1 {
93+
continue
94+
}
95+
96+
key := strings.TrimSpace(line[:colonIdx])
97+
value := strings.TrimSpace(line[colonIdx+1:])
98+
if value == "null" || value == "" {
99+
value = ""
100+
}
101+
102+
switch strings.ToLower(key) {
103+
case "severity":
104+
fields.Severity = value
105+
case "reason":
106+
fields.Reason = value
107+
case "escalated_by":
108+
fields.EscalatedBy = value
109+
case "escalated_at":
110+
fields.EscalatedAt = value
111+
case "acked_by":
112+
fields.AckedBy = value
113+
case "acked_at":
114+
fields.AckedAt = value
115+
case "closed_by":
116+
fields.ClosedBy = value
117+
case "closed_reason":
118+
fields.ClosedReason = value
119+
case "related_bead":
120+
fields.RelatedBead = value
121+
}
122+
}
123+
124+
return fields
125+
}
126+
127+
// CreateEscalationBead creates an escalation bead for tracking escalations.
128+
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
129+
func (b *Beads) CreateEscalationBead(title string, fields *EscalationFields) (*Issue, error) {
130+
description := FormatEscalationDescription(title, fields)
131+
132+
args := []string{"create", "--json",
133+
"--title=" + title,
134+
"--description=" + description,
135+
"--type=task",
136+
"--labels=gt:escalation",
137+
}
138+
139+
// Add severity as a label for easy filtering
140+
if fields != nil && fields.Severity != "" {
141+
args = append(args, fmt.Sprintf("--labels=severity:%s", fields.Severity))
142+
}
143+
144+
// Default actor from BD_ACTOR env var for provenance tracking
145+
if actor := os.Getenv("BD_ACTOR"); actor != "" {
146+
args = append(args, "--actor="+actor)
147+
}
148+
149+
out, err := b.run(args...)
150+
if err != nil {
151+
return nil, err
152+
}
153+
154+
var issue Issue
155+
if err := json.Unmarshal(out, &issue); err != nil {
156+
return nil, fmt.Errorf("parsing bd create output: %w", err)
157+
}
158+
159+
return &issue, nil
160+
}
161+
162+
// AckEscalation acknowledges an escalation bead.
163+
// Sets acked_by and acked_at fields, adds "acked" label.
164+
func (b *Beads) AckEscalation(id, ackedBy string) error {
165+
// First get current issue to preserve other fields
166+
issue, err := b.Show(id)
167+
if err != nil {
168+
return err
169+
}
170+
171+
// Verify it's an escalation
172+
if !HasLabel(issue, "gt:escalation") {
173+
return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
174+
}
175+
176+
// Parse existing fields
177+
fields := ParseEscalationFields(issue.Description)
178+
fields.AckedBy = ackedBy
179+
fields.AckedAt = time.Now().Format(time.RFC3339)
180+
181+
// Format new description
182+
description := FormatEscalationDescription(issue.Title, fields)
183+
184+
return b.Update(id, UpdateOptions{
185+
Description: &description,
186+
AddLabels: []string{"acked"},
187+
})
188+
}
189+
190+
// CloseEscalation closes an escalation bead with a resolution reason.
191+
// Sets closed_by and closed_reason fields, closes the issue.
192+
func (b *Beads) CloseEscalation(id, closedBy, reason string) error {
193+
// First get current issue to preserve other fields
194+
issue, err := b.Show(id)
195+
if err != nil {
196+
return err
197+
}
198+
199+
// Verify it's an escalation
200+
if !HasLabel(issue, "gt:escalation") {
201+
return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
202+
}
203+
204+
// Parse existing fields
205+
fields := ParseEscalationFields(issue.Description)
206+
fields.ClosedBy = closedBy
207+
fields.ClosedReason = reason
208+
209+
// Format new description
210+
description := FormatEscalationDescription(issue.Title, fields)
211+
212+
// Update description first
213+
if err := b.Update(id, UpdateOptions{
214+
Description: &description,
215+
AddLabels: []string{"resolved"},
216+
}); err != nil {
217+
return err
218+
}
219+
220+
// Close the issue
221+
_, err = b.run("close", id, "--reason="+reason)
222+
return err
223+
}
224+
225+
// GetEscalationBead retrieves an escalation bead by ID.
226+
// Returns nil if not found.
227+
func (b *Beads) GetEscalationBead(id string) (*Issue, *EscalationFields, error) {
228+
issue, err := b.Show(id)
229+
if err != nil {
230+
if errors.Is(err, ErrNotFound) {
231+
return nil, nil, nil
232+
}
233+
return nil, nil, err
234+
}
235+
236+
if !HasLabel(issue, "gt:escalation") {
237+
return nil, nil, fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
238+
}
239+
240+
fields := ParseEscalationFields(issue.Description)
241+
return issue, fields, nil
242+
}
243+
244+
// ListEscalations returns all open escalation beads.
245+
func (b *Beads) ListEscalations() ([]*Issue, error) {
246+
out, err := b.run("list", "--label=gt:escalation", "--status=open", "--json")
247+
if err != nil {
248+
return nil, err
249+
}
250+
251+
var issues []*Issue
252+
if err := json.Unmarshal(out, &issues); err != nil {
253+
return nil, fmt.Errorf("parsing bd list output: %w", err)
254+
}
255+
256+
return issues, nil
257+
}
258+
259+
// ListEscalationsBySeverity returns open escalation beads filtered by severity.
260+
func (b *Beads) ListEscalationsBySeverity(severity string) ([]*Issue, error) {
261+
out, err := b.run("list",
262+
"--label=gt:escalation",
263+
"--label=severity:"+severity,
264+
"--status=open",
265+
"--json",
266+
)
267+
if err != nil {
268+
return nil, err
269+
}
270+
271+
var issues []*Issue
272+
if err := json.Unmarshal(out, &issues); err != nil {
273+
return nil, fmt.Errorf("parsing bd list output: %w", err)
274+
}
275+
276+
return issues, nil
277+
}
278+
279+
// ListStaleEscalations returns escalations older than the given threshold.
280+
// threshold is a duration string like "1h" or "30m".
281+
func (b *Beads) ListStaleEscalations(threshold time.Duration) ([]*Issue, error) {
282+
// Get all open escalations
283+
escalations, err := b.ListEscalations()
284+
if err != nil {
285+
return nil, err
286+
}
287+
288+
cutoff := time.Now().Add(-threshold)
289+
var stale []*Issue
290+
291+
for _, issue := range escalations {
292+
// Skip acknowledged escalations
293+
if HasLabel(issue, "acked") {
294+
continue
295+
}
296+
297+
// Check if older than threshold
298+
createdAt, err := time.Parse(time.RFC3339, issue.CreatedAt)
299+
if err != nil {
300+
continue // Skip if can't parse
301+
}
302+
303+
if createdAt.Before(cutoff) {
304+
stale = append(stale, issue)
305+
}
306+
}
307+
308+
return stale, nil
309+
}

0 commit comments

Comments
 (0)