@@ 503,15 503,8 @@ func (v *beadsView) buildDetail(
continue
}
et := cell(ecols, r, "event_type")
- summary, text := humanizeEvent(et, cell(ecols, r, "old_value"), cell(ecols, r, "new_value"))
- if note := cell(ecols, r, "comment"); note != "" {
- // An event may carry its own free-text note alongside the change.
- if text != "" {
- text += "\n" + note
- } else {
- text = note
- }
- }
+ summary, text := humanizeEvent(et,
+ cell(ecols, r, "old_value"), cell(ecols, r, "new_value"), cell(ecols, r, "comment"))
data.History = append(data.History, BeadActivity{
Kind: "event",
Event: et,
@@ 689,14 682,21 @@ func sortActivity(acts []BeadActivity) {
// 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.
-func humanizeEvent(eventType, oldVal, newVal string) (summary, text string) {
+// 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.
- return "closed the issue", strings.TrimSpace(newVal)
+ // 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, ""
@@ 707,13 707,33 @@ func humanizeEvent(eventType, oldVal, newVal string) (summary, text string) {
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, ""
+ 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 ""
@@ 81,6 81,15 @@ func beadsFixture() *fakeSession {
},
Total: 2,
}
+ // i-done's closure is recorded as a `closed` audit event carrying the reason
+ // (the only place the reason now surfaces — there is no standalone block).
+ events := &browse.RowPage{
+ Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
+ Rows: [][]string{
+ {"e1", "i-done", "closed", "carol", "NULL", "Fixed in commit abc123", "NULL", "2024-01-03 12:00:00"},
+ },
+ Total: 1,
+ }
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
@@ 90,6 99,7 @@ func beadsFixture() *fakeSession {
"labels": labels,
"custom_statuses": statuses,
"comments": comments,
+ "events": events,
},
}
}
@@ 335,6 345,8 @@ func TestBeadsHandleViewDetail(t *testing.T) {
// A closed issue's detail pane must surface its close reason (and the closed
// timestamp), so the resolution recorded by `bd close -r` is not lost.
+// TestBeadsDetailShowsCloseReason: the close reason surfaces through the History
+// tab's `closed` event, not a standalone block (which was removed).
func TestBeadsDetailShowsCloseReason(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
@@ 346,11 358,15 @@ func TestBeadsDetailShowsCloseReason(t *testing.T) {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
- for _, want := range []string{"Close reason", "Fixed in commit abc123", "2024-01-04"} {
+ for _, want := range []string{"closed the issue", "Fixed in commit abc123"} {
if !strings.Contains(body, want) {
- t.Errorf("closed-issue detail missing %q; body=%s", want, body)
+ t.Errorf("closed-issue history missing %q; body=%s", want, body)
}
}
+ // The old standalone block is gone; the reason lives only in the timeline.
+ if strings.Contains(body, "<h4>Close reason</h4>") {
+ t.Errorf("standalone Close reason block should be removed; body=%s", body)
+ }
}
// --- epic mode + history -----------------------------------------------------
@@ 400,9 416,10 @@ func beadsEpicFixture() *fakeSession {
{"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"},
{"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"},
{"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"},
+ {"e4", "i-epic", "label_added", "Eugene", "NULL", "NULL", "Added label: milestone:m3", "2024-01-04 09:00:00"},
{"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"},
},
- Total: 4,
+ Total: 5,
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
@@ 459,9 476,9 @@ func TestBeadsHistoryMerge(t *testing.T) {
if len(d.Comments) != 1 || d.Comments[0].Text != "kickoff" {
t.Fatalf("comments = %+v, want just the epic's kickoff", d.Comments)
}
- // History merges the epic's 1 comment + 3 events (i-c1's are excluded), time-sorted.
- if len(d.History) != 4 {
- t.Fatalf("history len = %d, want 4: %+v", len(d.History), d.History)
+ // History merges the epic's 1 comment + 4 events (i-c1's are excluded), time-sorted.
+ if len(d.History) != 5 {
+ t.Fatalf("history len = %d, want 5: %+v", len(d.History), d.History)
}
for i := 1; i < len(d.History); i++ {
if d.History[i-1].CreatedAt > d.History[i].CreatedAt {
@@ 475,18 492,27 @@ func TestBeadsHistoryMerge(t *testing.T) {
if last.Kind != "comment" || last.Text != "kickoff" {
t.Errorf("last history = %+v, want the kickoff comment", last)
}
- // Humanized change lines.
- var sawStatus, sawUpdate bool
+ // Humanized change lines, and a label event collapsed to one line with no
+ // redundant "Added label:" body.
+ var sawStatus, sawUpdate, sawLabel bool
for _, a := range d.History {
switch a.Summary {
case "changed status to in_progress":
sawStatus = true
case "updated priority to 0":
sawUpdate = true
+ case "added label milestone:m3":
+ sawLabel = true
+ if a.Text != "" {
+ t.Errorf("label event should have no body, got %q", a.Text)
+ }
+ }
+ if strings.Contains(a.Text, "Added label:") {
+ t.Errorf("label note leaked into a history body: %+v", a)
}
}
- if !sawStatus || !sawUpdate {
- t.Errorf("history missing humanized change lines; status=%v update=%v", sawStatus, sawUpdate)
+ if !sawStatus || !sawUpdate || !sawLabel {
+ t.Errorf("history missing humanized lines; status=%v update=%v label=%v", sawStatus, sawUpdate, sawLabel)
}
}