~bigbes/sr-ht-dolt

57832d6cd691e6b4c5b5f60e441fb166cba5936b — Eugene Blikh 29 days ago 1b35456
feat(web/beads): close reason in Comments tab; synthesize subtask-add history

- 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.
3 files changed, 88 insertions(+), 21 deletions(-)

M web/beads.go
M web/beads_test.go
M web/templates/beads.html
M web/beads.go => web/beads.go +48 -0
@@ 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

M web/beads_test.go => web/beads_test.go +32 -20
@@ 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)
	}
}


M web/templates/beads.html => web/templates/beads.html +8 -1
@@ 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 @@
    </div>
    {{end}}
    {{else}}<p class="beads-empty">No comments.</p>{{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}}
    <h4>Close reason</h4>
    <pre class="field-body">{{.CloseReason}}</pre>
    {{end}}
  </div>

  <div class="actpanel panel-history">