From 57832d6cd691e6b4c5b5f60e441fb166cba5936b Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 19 Jul 2026 21:36:53 +0300 Subject: [PATCH] feat(web/beads): close reason in Comments tab; synthesize subtask-add history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Close reason placement: show it as a block at the end of the Comments tab (which carries no closed event), while the History tab shows it inline as the closed event — so it appears in whichever tab you're on, without duplication within a tab. (Reverts the outright removal.) - History now includes dependency/subtask additions. beads logs no audit event for a link, but the dependencies row records created_at/created_by, so buildDetail synthesizes "added subtask X" / "added dependency on X" timeline entries (Kind "dep", own accent). On an epic this surfaces when each child was linked. Guarded on created_at so older schemas without it emit nothing. - pre.field-body style de-scoped from .bead-detail so the Comments-tab close reason wraps (overflow-wrap: anywhere) instead of overflowing. --- web/beads.go | 48 +++++++++++++++++++++++++++++++++++++ web/beads_test.go | 52 ++++++++++++++++++++++++---------------- web/templates/beads.html | 9 ++++++- 3 files changed, 88 insertions(+), 21 deletions(-) diff --git a/web/beads.go b/web/beads.go index 7565d30c84d840ce9d338ac73890926e111827db..7e09f7955ede1d011ba21ab51f7364a75ca76576 100644 --- a/web/beads.go +++ b/web/beads.go @@ -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: // "; 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 diff --git a/web/beads_test.go b/web/beads_test.go index 76f27f7f6a8eb0733f5d27a0ce6b771d4d7491d5..b1f164b48097b542476d0f7609d852aa872872b8 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -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, "

Close reason

") { + 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, "

Close reason

") { - 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) } } diff --git a/web/templates/beads.html b/web/templates/beads.html index a096d851a3802c4ce9b7e1f4031ca31d69939f0c..52cf89c40121052a210866e6e86ea5aaa2173f0c 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -82,7 +82,7 @@ .bead-detail .lane-label .swatch { margin-right: .3rem; } .bead-detail .field-label { color: var(--bd-muted); font-weight: 600; font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; } .bead-detail table td.field-label { white-space: nowrap; width: 9rem; vertical-align: middle; } -.bead-detail pre.field-body { +pre.field-body { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bd-panel); border: 1px solid var(--bd-border); color: var(--bd-fg); padding: .5rem; margin: .25rem 0 1rem; } @@ -129,6 +129,7 @@ width: 8px; height: 8px; background: var(--bd-muted); border: 1px solid var(--bd-bg); } .bead-timeline .tl-comment::before { background: var(--lane-linedup); } +.bead-timeline .tl-dep::before { background: var(--lane-stalled); } .bead-timeline .tl-head { font-size: .85rem; color: var(--bd-fg); } .bead-timeline .tl-when { color: var(--bd-muted); } .bead-timeline .tl-body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--bd-fg); margin-top: .15rem; padding-left: .1rem; } @@ -246,6 +247,12 @@ {{end}} {{else}}

No comments.

{{end}} + {{/* The Comments tab has no closed event, so the close reason is shown here + at the end; the History tab already carries it as the closed event. */}} + {{if .CloseReason}} +

Close reason

+
{{.CloseReason}}
+ {{end}}