|
| 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