|
| 1 | +package notifier |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "fmt" |
| 6 | + "log" |
| 7 | + "os/exec" |
| 8 | + "sort" |
| 9 | + "strings" |
| 10 | + "sync" |
| 11 | + "time" |
| 12 | + |
| 13 | + "github.com/ding-labs/ding/internal/evaluator" |
| 14 | +) |
| 15 | + |
| 16 | +// cmdRunner runs the buildkite-agent CLI. Tests inject a stub. |
| 17 | +type cmdRunner func(args []string, stdin string) error |
| 18 | + |
| 19 | +func defaultCmdRunner(args []string, stdin string) error { |
| 20 | + if len(args) == 0 { |
| 21 | + return fmt.Errorf("cmdRunner: empty args") |
| 22 | + } |
| 23 | + cmd := exec.Command(args[0], args[1:]...) |
| 24 | + cmd.Stdin = strings.NewReader(stdin) |
| 25 | + var stderr bytes.Buffer |
| 26 | + cmd.Stderr = &stderr |
| 27 | + if err := cmd.Run(); err != nil { |
| 28 | + msg := strings.TrimSpace(stderr.String()) |
| 29 | + if msg == "" { |
| 30 | + return err |
| 31 | + } |
| 32 | + return fmt.Errorf("%w (stderr: %s)", err, msg) |
| 33 | + } |
| 34 | + return nil |
| 35 | +} |
| 36 | + |
| 37 | +// lookPath is overridable for tests. |
| 38 | +var lookPath = exec.LookPath |
| 39 | + |
| 40 | +// BuildkiteAnnotateNotifier publishes DING alerts as Buildkite build |
| 41 | +// annotations by shelling out to `buildkite-agent annotate`. All alerts |
| 42 | +// for a build land in a single rolling annotation (--context ding |
| 43 | +// --append). First Send writes a `# DING Alerts` H1 header; subsequent |
| 44 | +// Sends append `## <rule>` sections only — Buildkite's --append mode |
| 45 | +// concatenates each invocation's stdin into the existing annotation body. |
| 46 | +// |
| 47 | +// Sync, mutex-guarded. Outside Buildkite (buildkite-agent not on PATH), |
| 48 | +// logs once at construction and Send becomes a no-op — same graceful- |
| 49 | +// degrade philosophy as the github_actions notifier. |
| 50 | +type BuildkiteAnnotateNotifier struct { |
| 51 | + mu sync.Mutex |
| 52 | + style string |
| 53 | + runner cmdRunner |
| 54 | + available bool // false if buildkite-agent not on PATH at construction |
| 55 | + wroteHeader bool |
| 56 | +} |
| 57 | + |
| 58 | +// NewBuildkiteAnnotateNotifier constructs a notifier that publishes DING |
| 59 | +// alerts as Buildkite annotations. style defaults to "error" if empty; |
| 60 | +// validated by config.Validate. Caller is responsible for ensuring style |
| 61 | +// is one of "success", "info", "warning", "error". |
| 62 | +func NewBuildkiteAnnotateNotifier(style string) *BuildkiteAnnotateNotifier { |
| 63 | + if style == "" { |
| 64 | + style = "error" |
| 65 | + } |
| 66 | + n := &BuildkiteAnnotateNotifier{ |
| 67 | + style: style, |
| 68 | + runner: defaultCmdRunner, |
| 69 | + } |
| 70 | + if _, err := lookPath("buildkite-agent"); err != nil { |
| 71 | + log.Printf("ding: buildkite_annotate notifier: buildkite-agent not on PATH; alerts via this notifier will be no-ops") |
| 72 | + n.available = false |
| 73 | + } else { |
| 74 | + n.available = true |
| 75 | + } |
| 76 | + return n |
| 77 | +} |
| 78 | + |
| 79 | +// Send invokes `buildkite-agent annotate --style <style> --context ding |
| 80 | +// --append` with the rendered Markdown as stdin. No-op (returns nil) |
| 81 | +// if buildkite-agent wasn't on PATH at construction. |
| 82 | +func (n *BuildkiteAnnotateNotifier) Send(alert evaluator.Alert) error { |
| 83 | + n.mu.Lock() |
| 84 | + defer n.mu.Unlock() |
| 85 | + |
| 86 | + if !n.available { |
| 87 | + return nil |
| 88 | + } |
| 89 | + |
| 90 | + body := n.renderBody(alert) |
| 91 | + args := []string{"buildkite-agent", "annotate", "--style", n.style, "--context", "ding", "--append"} |
| 92 | + if err := n.runner(args, body); err != nil { |
| 93 | + return fmt.Errorf("buildkite_annotate Send: %w", err) |
| 94 | + } |
| 95 | + return nil |
| 96 | +} |
| 97 | + |
| 98 | +// renderBody produces the per-invocation stdin payload. Caller must hold n.mu; |
| 99 | +// modifies n.wroteHeader. |
| 100 | +func (n *BuildkiteAnnotateNotifier) renderBody(alert evaluator.Alert) string { |
| 101 | + var b strings.Builder |
| 102 | + if !n.wroteHeader { |
| 103 | + b.WriteString("# DING Alerts\n\n") |
| 104 | + n.wroteHeader = true |
| 105 | + } |
| 106 | + fmt.Fprintf(&b, "## %s\n\n", alert.Rule) |
| 107 | + if alert.Message != "" { |
| 108 | + fmt.Fprintf(&b, "%s\n\n", alert.Message) |
| 109 | + } |
| 110 | + fmt.Fprintf(&b, "- **Metric:** `%s`\n", alert.Metric) |
| 111 | + fmt.Fprintf(&b, "- **Value:** `%v`\n", alert.Value) |
| 112 | + fmt.Fprintf(&b, "- **Fired:** `%s`\n", alert.FiredAt.Format(time.RFC3339)) |
| 113 | + if alert.Count > 0 || alert.Avg != 0 || alert.Sum != 0 { |
| 114 | + fmt.Fprintf(&b, "- **Aggregates:** count=`%v` avg=`%v` min=`%v` max=`%v` sum=`%v`\n", |
| 115 | + alert.Count, alert.Avg, alert.Min, alert.Max, alert.Sum) |
| 116 | + } |
| 117 | + if len(alert.Labels) > 0 { |
| 118 | + b.WriteString("- **Labels:**\n") |
| 119 | + // Stable order so concatenated annotation diffs cleanly between Sends. |
| 120 | + keys := make([]string, 0, len(alert.Labels)) |
| 121 | + for k := range alert.Labels { |
| 122 | + keys = append(keys, k) |
| 123 | + } |
| 124 | + sort.Strings(keys) |
| 125 | + for _, k := range keys { |
| 126 | + fmt.Fprintf(&b, " - `%s`: `%s`\n", k, alert.Labels[k]) |
| 127 | + } |
| 128 | + } |
| 129 | + b.WriteString("\n") |
| 130 | + return b.String() |
| 131 | +} |
0 commit comments