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