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: // "; 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 the browse NULL placeholder // and non-object payloads (returns nil rather than erroring). func decodeJSONObject(raw string) map[string]any { raw = strings.TrimSpace(raw) if raw == "" || raw == "NULL" { 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) } }