@@ 469,6 469,13 @@ func (v *beadsView) buildDetail(
}
}
}
+ // beads logs no event for a dependency/subtask link, but the row records
+ // created_at/created_by — synthesize a timeline entry so "added subtask X"
+ // (and other edge additions) appear in History.
+ if act, ok := depActivity(want, from, to, typ,
+ cell(depCols, r, "created_at"), cell(depCols, r, "created_by")); ok {
+ data.History = append(data.History, act)
+ }
}
sortSubtasks(data.Subtasks)
@@ 720,6 727,47 @@ func humanizeEvent(eventType, oldVal, newVal, note string) (summary, text string
}
}
+// depActivity synthesizes a History entry for a dependency edge touching `want`.
+// beads emits no audit event when a link is added, but the dependencies row
+// carries created_at/created_by, so edge additions — most usefully subtasks
+// linked under an epic — still appear on the timeline. Returns ok=false when the
+// edge does not touch `want` or the row has no timestamp (older schema without
+// created_at: skip rather than emit a blank-dated entry).
+func depActivity(want, from, to, typ, at, by string) (BeadActivity, bool) {
+ if at == "" || (from != want && to != want) {
+ return BeadActivity{}, false
+ }
+ var summary string
+ switch strings.ToLower(strings.TrimSpace(typ)) {
+ case "parent-child":
+ if to == want {
+ summary = "added subtask " + from // want is the epic/parent
+ } else {
+ summary = "added under epic " + to // want is the child
+ }
+ case "blocks":
+ if from == want {
+ summary = "added dependency on " + to
+ } else {
+ summary = from + " now depends on this"
+ }
+ case "related":
+ // Related is symmetric; emit once (from the issue_id side) to avoid a
+ // duplicate entry on both endpoints.
+ if from != want {
+ return BeadActivity{}, false
+ }
+ summary = "linked " + to + " (related)"
+ default:
+ if from == want {
+ summary = "added " + typ + " dependency on " + to
+ } else {
+ return BeadActivity{}, false
+ }
+ }
+ return BeadActivity{Kind: "dep", Event: "dependency", Actor: by, Summary: summary, CreatedAt: at}, true
+}
+
// 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
@@ 345,8 345,9 @@ 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).
+// TestBeadsDetailShowsCloseReason: the reason appears twice by design — once in
+// the Comments tab (which has no closed event) as a Close reason block, and once
+// in the History tab as the humanized `closed` event.
func TestBeadsDetailShowsCloseReason(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
@@ 358,14 359,17 @@ 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{"closed the issue", "Fixed in commit abc123"} {
- if !strings.Contains(body, want) {
- t.Errorf("closed-issue history missing %q; body=%s", want, body)
- }
+ // Comments-tab block.
+ if !strings.Contains(body, "<h4>Close reason</h4>") {
+ t.Errorf("Comments tab should show a Close reason block; body=%s", body)
+ }
+ // History-tab closed event.
+ if !strings.Contains(body, "closed the issue") {
+ t.Errorf("History should carry the closed event; body=%s", 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)
+ // The reason text itself is present (both places).
+ if !strings.Contains(body, "Fixed in commit abc123") {
+ t.Errorf("close reason text missing; body=%s", body)
}
}
@@ 385,13 389,14 @@ func beadsEpicFixture() *fakeSession {
},
Total: 4,
}
- // Each child is the "from" side of a parent-child edge pointing at the epic.
+ // Each child is the "from" side of a parent-child edge pointing at the epic;
+ // created_at/created_by let the view synthesize "added subtask" history.
deps := &browse.RowPage{
- Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
+ Columns: []string{"id", "issue_id", "depends_on_issue_id", "type", "created_at", "created_by"},
Rows: [][]string{
- {"d1", "i-c1", "i-epic", "parent-child"},
- {"d2", "i-c2", "i-epic", "parent-child"},
- {"d3", "i-c3", "i-epic", "parent-child"},
+ {"d1", "i-c1", "i-epic", "parent-child", "2024-01-01 09:00:00", "Eugene"},
+ {"d2", "i-c2", "i-epic", "parent-child", "2024-01-01 09:05:00", "Eugene"},
+ {"d3", "i-c3", "i-epic", "parent-child", "2024-01-01 09:10:00", "Eugene"},
},
Total: 3,
}
@@ 476,9 481,10 @@ 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 + 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)
+ // History merges the epic's 1 comment + 4 events + 3 synthesized subtask-add
+ // entries (i-c1's own event is excluded), time-sorted.
+ if len(d.History) != 8 {
+ t.Fatalf("history len = %d, want 8: %+v", len(d.History), d.History)
}
for i := 1; i < len(d.History); i++ {
if d.History[i-1].CreatedAt > d.History[i].CreatedAt {
@@ 494,7 500,7 @@ func TestBeadsHistoryMerge(t *testing.T) {
}
// Humanized change lines, and a label event collapsed to one line with no
// redundant "Added label:" body.
- var sawStatus, sawUpdate, sawLabel bool
+ var sawStatus, sawUpdate, sawLabel, sawSubtask bool
for _, a := range d.History {
switch a.Summary {
case "changed status to in_progress":
@@ 506,13 512,19 @@ func TestBeadsHistoryMerge(t *testing.T) {
if a.Text != "" {
t.Errorf("label event should have no body, got %q", a.Text)
}
+ case "added subtask i-c1":
+ sawSubtask = true
+ if a.Kind != "dep" || a.Actor != "Eugene" {
+ t.Errorf("subtask-add entry = %+v, want kind=dep actor=Eugene", a)
+ }
}
if strings.Contains(a.Text, "Added label:") {
t.Errorf("label note leaked into a history body: %+v", a)
}
}
- if !sawStatus || !sawUpdate || !sawLabel {
- t.Errorf("history missing humanized lines; status=%v update=%v label=%v", sawStatus, sawUpdate, sawLabel)
+ if !sawStatus || !sawUpdate || !sawLabel || !sawSubtask {
+ t.Errorf("history missing lines; status=%v update=%v label=%v subtask=%v",
+ sawStatus, sawUpdate, sawLabel, sawSubtask)
}
}