~bigbes/sr-ht-dolt

1b35456ae619d7fe15eb0acad7bac1d2af7702e1 — Eugene Blikh 29 days ago 2ee66fb
refine(web/beads): concise label events, drop standalone close reason, wrap long lines

- Label audit events collapse to one line ("added label milestone:m3") instead
  of a "label added" header plus a redundant "Added label: …" body. The label
  name is taken after the first colon so namespaced labels survive.
- Remove the standalone Close reason block below the activity: the reason
  already shows as the History tab's `closed` event, so it was a duplicate.
- Add overflow-wrap: anywhere to the timeline body, comment body, and
  field-body pre so a very long single-line reason wraps instead of overflowing.
3 files changed, 74 insertions(+), 33 deletions(-)

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

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


M web/templates/beads.html => web/templates/beads.html +4 -9
@@ 83,8 83,8 @@
.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 {
  white-space: pre-wrap; background: var(--bd-panel); border: 1px solid var(--bd-border);
  color: var(--bd-fg); padding: .5rem; margin: .25rem 0 1rem;
  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;
}
.bead-deplist { list-style: none; padding-left: 0; margin-bottom: 0; }
.bead-deplist li { padding: .2rem 0; border-top: 1px solid var(--bd-border); }


@@ 92,7 92,7 @@
.bead-deplist code { font-size: .8rem; }
.bead-comment { border: 1px solid var(--bd-border); border-left: 2px solid var(--lane-linedup); padding: .4rem .6rem; margin-bottom: .5rem; }
.bead-comment .c-head { font-size: .8rem; color: var(--bd-muted); margin-bottom: .2rem; }
.bead-comment .c-body { white-space: pre-wrap; color: var(--bd-fg); }
.bead-comment .c-body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--bd-fg); }

/* epic: badge, subtask rollup, progress meter */
.epic-badge { border-color: var(--lane-stalled); color: var(--bd-fg); text-transform: uppercase; letter-spacing: .04em; }


@@ 131,7 131,7 @@
.bead-timeline .tl-comment::before { background: var(--lane-linedup); }
.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; color: var(--bd-fg); margin-top: .15rem; padding-left: .1rem; }
.bead-timeline .tl-body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--bd-fg); margin-top: .15rem; padding-left: .1rem; }
</style>

<div class="beads">


@@ 264,11 264,6 @@
    {{else}}<p class="beads-empty">No activity yet.</p>{{end}}
  </div>
</div>

{{if .CloseReason}}
<h4>Close reason</h4>
<pre class="field-body">{{.CloseReason}}</pre>
{{end}}
{{else}}
<div class="alert alert-warning">Issue not found.</div>
{{end}}