package beads
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
// humanizeEvent turns one audit row into a readable summary line (and optional
// body text). status_changed / updated carry a JSON new_value fragment
// ({"status":"in_progress"}, {"priority":0}); created and closed are lifecycle
// markers, with closed's new_value holding the free-text close reason; label
// events keep their whole story in the comment note, so they collapse to a
// single summary line rather than a "label added" header + redundant body.
func humanizeEvent(eventType, oldVal, newVal, note string) (summary, text string) {
note = strings.TrimSpace(note)
switch strings.ToLower(strings.TrimSpace(eventType)) {
case "created":
return "created the issue", ""
case "closed":
// new_value is the close reason (plain text), not JSON; older rows put it
// in the note instead.
if r := strings.TrimSpace(newVal); r != "" {
return "closed the issue", r
}
return "closed the issue", note
case "status_changed":
if s := jsonField(newVal, "status"); s != "" {
return "changed status to " + s, ""
}
return "changed status", ""
case "updated":
if pairs := jsonPairs(newVal); pairs != "" {
return "updated " + pairs, ""
}
return "updated the issue", ""
case "label_added":
return labelLine(note, "added"), ""
case "label_removed":
return labelLine(note, "removed"), ""
default:
et := strings.ReplaceAll(strings.TrimSpace(eventType), "_", " ")
if et == "" {
et = "changed"
}
return et, note
}
}
// labelLine collapses a label event to one line. The note reads "Added label:
// <name>"; we drop everything up to the FIRST colon (the "Added label:" prefix)
// and keep the rest, so a namespaced label like "milestone:m3" survives intact
// and the summary becomes "added label milestone:m3".
func labelLine(note, verb string) string {
name := note
if i := strings.Index(name, ":"); i >= 0 {
name = name[i+1:]
}
name = strings.TrimSpace(name)
if name == "" {
return verb + " a label"
}
return verb + " label " + name
}
// jsonField extracts one string-ish field from a JSON object fragment, or ""
// when the value is not a JSON object or the key is absent.
func jsonField(raw, key string) string {
m := decodeJSONObject(raw)
if m == nil {
return ""
}
if v, ok := m[key]; ok {
return scalarString(v)
}
return ""
}
// jsonPairs renders a JSON object fragment as "k to v, k2 to v2", used for the
// "updated …" summary. Keys are sorted for a deterministic line.
func jsonPairs(raw string) string {
m := decodeJSONObject(raw)
if len(m) == 0 {
return ""
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+" to "+scalarString(m[k]))
}
return strings.Join(parts, ", ")
}
// decodeJSONObject parses raw into a map, tolerating an empty value and
// non-object payloads (returns nil rather than erroring).
//
// It used to special-case the string "NULL" as well, back when that string was
// how an absent value reached it. cell answers an absent value as "" now, so a
// "NULL" arriving here is four characters a row actually stores — which is not a
// JSON object, and takes the same nil the parse error gives it.
func decodeJSONObject(raw string) map[string]any {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil
}
return m
}
// scalarString renders a decoded JSON scalar the way a person would read it:
// integers without a trailing ".0", everything else via fmt.
func scalarString(v any) string {
switch t := v.(type) {
case string:
return t
case float64:
if t == float64(int64(t)) {
return strconv.FormatInt(int64(t), 10)
}
return strconv.FormatFloat(t, 'g', -1, 64)
case bool:
if t {
return "true"
}
return "false"
case nil:
return ""
default:
return fmt.Sprintf("%v", t)
}
}