coverage~bigbes/sr-ht-dolt3523280cbeads/events.go

Coverage
66.1% 39/59 statements
Δ
+0.0
Blob
a0de93f
1 package beads
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7 "strconv"
8 "strings"
9 )
10
11 // humanizeEvent turns one audit row into a readable summary line (and optional
12 // body text). status_changed / updated carry a JSON new_value fragment
13 // ({"status":"in_progress"}, {"priority":0}); created and closed are lifecycle
14 // markers, with closed's new_value holding the free-text close reason; label
15 // events keep their whole story in the comment note, so they collapse to a
16 // single summary line rather than a "label added" header + redundant body.
17 17 func humanizeEvent(eventType, oldVal, newVal, note string) (summary, text string) {
18 17 note = strings.TrimSpace(note)
19 17 switch strings.ToLower(strings.TrimSpace(eventType)) {
20 4 case "created":
21 4 return "created the issue", ""
22 1 case "closed":
23 1 // new_value is the close reason (plain text), not JSON; older rows put it
24 1 // in the note instead.
25 1 if r := strings.TrimSpace(newVal); r != "" {
26 1 return "closed the issue", r
27 1 }
28 0 return "closed the issue", note
29 4 case "status_changed":
30 4 if s := jsonField(newVal, "status"); s != "" {
31 4 return "changed status to " + s, ""
32 4 }
33 0 return "changed status", ""
34 4 case "updated":
35 4 if pairs := jsonPairs(newVal); pairs != "" {
36 4 return "updated " + pairs, ""
37 4 }
38 0 return "updated the issue", ""
39 4 case "label_added":
40 4 return labelLine(note, "added"), ""
41 0 case "label_removed":
42 0 return labelLine(note, "removed"), ""
43 0 default:
44 0 et := strings.ReplaceAll(strings.TrimSpace(eventType), "_", " ")
45 0 if et == "" {
46 0 et = "changed"
47 0 }
48 0 return et, note
49 }
50 }
51
52 // labelLine collapses a label event to one line. The note reads "Added label:
53 // <name>"; we drop everything up to the FIRST colon (the "Added label:" prefix)
54 // and keep the rest, so a namespaced label like "milestone:m3" survives intact
55 // and the summary becomes "added label milestone:m3".
56 4 func labelLine(note, verb string) string {
57 4 name := note
58 4 if i := strings.Index(name, ":"); i >= 0 {
59 4 name = name[i+1:]
60 4 }
61 4 name = strings.TrimSpace(name)
62 4 if name == "" {
63 0 return verb + " a label"
64 0 }
65 4 return verb + " label " + name
66 }
67
68 // jsonField extracts one string-ish field from a JSON object fragment, or ""
69 // when the value is not a JSON object or the key is absent.
70 4 func jsonField(raw, key string) string {
71 4 m := decodeJSONObject(raw)
72 4 if m == nil {
73 0 return ""
74 0 }
75 4 if v, ok := m[key]; ok {
76 4 return scalarString(v)
77 4 }
78 0 return ""
79 }
80
81 // jsonPairs renders a JSON object fragment as "k to v, k2 to v2", used for the
82 // "updated …" summary. Keys are sorted for a deterministic line.
83 4 func jsonPairs(raw string) string {
84 4 m := decodeJSONObject(raw)
85 4 if len(m) == 0 {
86 0 return ""
87 0 }
88 4 keys := make([]string, 0, len(m))
89 4 for k := range m {
90 4 keys = append(keys, k)
91 4 }
92 4 sort.Strings(keys)
93 4 parts := make([]string, 0, len(keys))
94 4 for _, k := range keys {
95 4 parts = append(parts, k+" to "+scalarString(m[k]))
96 4 }
97 4 return strings.Join(parts, ", ")
98 }
99
100 // decodeJSONObject parses raw into a map, tolerating an empty value and
101 // non-object payloads (returns nil rather than erroring).
102 //
103 // It used to special-case the string "NULL" as well, back when that string was
104 // how an absent value reached it. cell answers an absent value as "" now, so a
105 // "NULL" arriving here is four characters a row actually stores — which is not a
106 // JSON object, and takes the same nil the parse error gives it.
107 8 func decodeJSONObject(raw string) map[string]any {
108 8 raw = strings.TrimSpace(raw)
109 8 if raw == "" {
110 0 return nil
111 0 }
112 8 var m map[string]any
113 8 if err := json.Unmarshal([]byte(raw), &m); err != nil {
114 0 return nil
115 0 }
116 8 return m
117 }
118
119 // scalarString renders a decoded JSON scalar the way a person would read it:
120 // integers without a trailing ".0", everything else via fmt.
121 8 func scalarString(v any) string {
122 8 switch t := v.(type) {
123 4 case string:
124 4 return t
125 4 case float64:
126 4 if t == float64(int64(t)) {
127 4 return strconv.FormatInt(int64(t), 10)
128 4 }
129 0 return strconv.FormatFloat(t, 'g', -1, 64)
130 0 case bool:
131 0 if t {
132 0 return "true"
133 0 }
134 0 return "false"
135 0 case nil:
136 0 return ""
137 0 default:
138 0 return fmt.Sprintf("%v", t)
139 }
140 }