From 8246555b216b4b6815e400b469c971786d42a873 Mon Sep 17 00:00:00 2001
From: Christopher Bays
Date: Sun, 14 Jun 2026 17:14:35 -0400
Subject: [PATCH 1/2] =?UTF-8?q?feat(render):=20PR=20review=20comments=20in?=
=?UTF-8?q?=20the=20report=20=E2=80=94=20inline=20threads=20+=20counts=20+?=
=?UTF-8?q?=20navigation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Consolidates #419 + #420 + #421 + #422 (epic #353): the ingested GitHub PR
review threads (#395) are anchored onto the rendered diff (the shipped #418
anchoring — re-used verbatim, never re-anchored) and grouped per model, then
rendered three ways — gated behind the new `pr-comments` experiment + the
`--pr-diff` arm.
- #419 inline diff-line comment threads: each anchored thread renders at its
Model-SQL diff line (author · body · replies · resolved/open state,
GitHub-style). Resolved threads collapse; outdated threads are labeled and
surfaced in a per-model tail (never mis-anchored, never dropped).
- #420 top-of-report comment-count button: shows the report-wide total and
navigates (selects the model via __cuteSelectNode's selectModelByName +
scrolls to the anchored diff line). In-page JS only — zero fetch.
- #421 per-model count tooltip: the focusable-bubble pattern (.expect-tooltip
+ CSS bubble on :hover AND :focus, aria-label for AT) — never a native title.
- #422 fixture + BDD + dogfood: synthetic --pr-comments review-threads fixture
drives the committed comments-showcase golden (visible inline threads +
counts); 5 BDD scenarios; headless test asserting inline render, navigation,
and tooltip-on-focus; zero-egress gate covers the example.
Domain: new pure `pr_comment_render` module (CommentsView / ModelCommentBucket
/ group_comment_threads) — std+serde only, re-uses anchor_comment_thread.
Adapter: additive Option on ReportPayload (JSON-rendered, the
pr_dag precedent). CLI: --pr-comments @file value-parser (the gh-fetch live
path resolves owner/repo/number from --pr-url/--pr-number) + gather_pr_comments.
Gating: byte-identity goldens when the experiment is OFF / no PR context / no
comments (the always-present static containers stay empty). All report goldens
regenerated for the JS/CSS bundle shift; explore goldens untouched.
Closes #419
Closes #420
Closes #421
Closes #422
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/ci.yml | 37 +-
examples/comments-showcase-report.html | 11700 ++++++++++++++++
examples/diff-showcase-report.html | 313 +
examples/jaffle-shop-report.html | 313 +
examples/macro-heavy-report.html | 313 +
examples/playground-report.html | 313 +
examples/prdiff-minidag-report.html | 313 +
examples/seed-showcase-report.html | 313 +
features/pr_comments.feature | 69 +
lefthook.yml | 3 +-
src/adapters/render.rs | 70 +-
src/cli/args.rs | 27 +
src/cli/mod.rs | 127 +-
src/cli/pr_comments.rs | 113 +
src/cli/review.rs | 7 +
src/domain/experimental.rs | 31 +
src/domain/mod.rs | 4 +
src/domain/pr_comment_render.rs | 684 +
templates/interaction.js | 248 +
templates/report.css | 60 +
templates/report.html | 24 +
tests/common/mod.rs | 5 +
tests/fixture_parse.rs | 9 +-
tests/fixtures/MANIFEST.toml | 26 +
.../comments-showcase-pr-comments.json | 85 +
tests/headless_toggle.rs | 197 +-
...lden_report__rendered_report_skeleton.snap | 6 +-
...egration__rendered_chrome_jaffle_shop.snap | 313 +
tests/steps/mod.rs | 1 +
tests/steps/pr_comments.rs | 223 +
30 files changed, 15923 insertions(+), 24 deletions(-)
create mode 100644 examples/comments-showcase-report.html
create mode 100644 features/pr_comments.feature
create mode 100644 src/cli/pr_comments.rs
create mode 100644 src/domain/pr_comment_render.rs
create mode 100644 tests/fixtures/comments-showcase-pr-comments.json
create mode 100644 tests/steps/pr_comments.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6cb63f7a..7dae5e71 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -343,6 +343,7 @@ jobs:
# - 28 after agent_skill (cute-dbt#304)
# - 29 after macro_perspective (cute-dbt#265)
# - 30 after explore_macro_view (cute-dbt#345)
+ # - 31 after pr_comments (cute-dbt#419)
# The README is not a feature. The job name deliberately carries no
# number (cute-dbt#68 — the old `==6` display label went stale across
# several bumps): the enforced count lives ONLY in the `expected=`
@@ -360,7 +361,7 @@ jobs:
- name: features/*.feature count matches expected
run: |
set -euo pipefail
- expected=30
+ expected=31
count=$(find features -maxdepth 1 -name '*.feature' | wc -l | tr -d ' ')
echo "feature-count: expected ${expected}, found ${count}"
if [ "$count" != "$expected" ]; then
@@ -710,6 +711,24 @@ jobs:
output: examples/prdiff-minidag-report.html
project_root: tests/fixtures/prdiff-minidag-source
experimental: "1"
+ # cute-dbt#419–#422 (epic #353) — the GOLDEN PR review-comments
+ # showcase. REUSES the prdiff-minidag manifest + patch + source (so
+ # no manifest regen — the threads anchor onto its real diff lines)
+ # and adds a SYNTHETIC `gh api graphql` review-threads payload via
+ # --pr-comments. The `pr-comments` experiment is on, so the committed
+ # golden exercises the gated surface: an inline OPEN thread on
+ # stg_orders line 2, a RESOLVED thread + an OUTDATED tail on
+ # fct_orders, the per-model count tooltip, and the top-of-report
+ # count button (3 comments total). No baseline — --pr-diff only.
+ - name: comments-showcase
+ mode: report
+ current: tests/fixtures/prdiff-minidag-current.json
+ baseline: ""
+ pr_diff: tests/fixtures/prdiff-minidag-pr-diff.patch
+ output: examples/comments-showcase-report.html
+ project_root: tests/fixtures/prdiff-minidag-source
+ experimental: "1"
+ pr_comments: tests/fixtures/comments-showcase-pr-comments.json
# cute-dbt#100 — the GOLDEN explore pages: the full synthetic
# playground manifest rendered to the two-file --out-dir shape
# (examples/explore/dag.html + tests.html), each independently
@@ -790,6 +809,9 @@ jobs:
# pins a far-future date so the golden is wall-clock-independent.
FINDINGS_OUT: ${{ matrix.findings_out }}
GENERATED_AT: ${{ matrix.generated_at }}
+ # cute-dbt#419: optional synthetic PR review-comments payload (the
+ # comments-showcase row); empty = no --pr-comments arg.
+ PR_COMMENTS: ${{ matrix.pr_comments }}
run: |
set -euo pipefail
mkdir -p target/example-check
@@ -889,16 +911,25 @@ jobs:
findings_args=(--findings-out "$rendered_findings" --generated-at "$GENERATED_AT")
findings_hint=" --findings-out $FINDINGS_OUT --generated-at $GENERATED_AT"
fi
+ # cute-dbt#419: the optional --pr-comments arg (the comments-showcase
+ # row's synthetic review-threads payload). Built only when set.
+ pr_comments_args=()
+ pr_comments_hint=""
+ if [ -n "$PR_COMMENTS" ]; then
+ pr_comments_args=(--pr-comments "@$PR_COMMENTS")
+ pr_comments_hint=" --pr-comments @$PR_COMMENTS"
+ fi
cargo run --quiet --locked --bin cute-dbt -- report \
--manifest "$CURRENT_MANIFEST" \
"${scope_args[@]}" \
"${project_root_args[@]}" \
"${config_args[@]}" \
"${findings_args[@]}" \
+ "${pr_comments_args[@]}" \
--out "$rendered"
if ! diff -q "$rendered" "$OUTPUT_PATH" >/dev/null; then
echo "::error::example-report-up-to-date: $OUTPUT_PATH does not match the renderer output ($EXAMPLE_NAME). Regenerate with:"
- echo " ${regen_env}cargo run --bin cute-dbt -- report --manifest $CURRENT_MANIFEST$scope_hint$regen_hint$config_hint$findings_hint --out $OUTPUT_PATH"
+ echo " ${regen_env}cargo run --bin cute-dbt -- report --manifest $CURRENT_MANIFEST$scope_hint$regen_hint$config_hint$findings_hint$pr_comments_hint --out $OUTPUT_PATH"
echo "Sizes:"
ls -l "$rendered" "$OUTPUT_PATH"
exit 1
@@ -910,7 +941,7 @@ jobs:
if [ -n "$FINDINGS_OUT" ]; then
if ! diff -q "$rendered_findings" "$FINDINGS_OUT" >/dev/null; then
echo "::error::example-report-up-to-date: $FINDINGS_OUT does not match the renderer output ($EXAMPLE_NAME findings envelope). Regenerate with:"
- echo " ${regen_env}cargo run --bin cute-dbt -- report --manifest $CURRENT_MANIFEST$scope_hint$regen_hint$config_hint$findings_hint --out $OUTPUT_PATH"
+ echo " ${regen_env}cargo run --bin cute-dbt -- report --manifest $CURRENT_MANIFEST$scope_hint$regen_hint$config_hint$findings_hint$pr_comments_hint --out $OUTPUT_PATH"
echo "Sizes:"
ls -l "$rendered_findings" "$FINDINGS_OUT"
exit 1
diff --git a/examples/comments-showcase-report.html b/examples/comments-showcase-report.html
new file mode 100644
index 00000000..080fff93
--- /dev/null
+++ b/examples/comments-showcase-report.html
@@ -0,0 +1,11700 @@
+
+
+
+
+
+cute-dbt report
+
+
+
+
+
+
+
+
+
+
+ Showing 2 unit tests in scope — from PR file diff.
+
+
+ no unit-test definitions changed in this diff
+
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
+
Appearance
+
+
Style
+
+ Soft
+ Paper
+ Swiss
+ Terminal
+
+
+
+
Theme
+
+ Light
+ Solarized
+ Catppuccin Latte
+ Rosé Pine Dawn
+ Dark
+ Tokyo Night
+ Gruvbox
+ Dracula
+
+
+
+
Accent
+
+
+
+
+
+
+
+
+
+
+
Density
+
+ Compact
+ Cozy
+ Roomy
+
+
+
+
Diff layoutside-by-side when there’s room
+
+ Auto
+ Unified
+ Split
+
+
+
+
+
DAG engineMermaid (static) or Cytoscape (interactive)
+
+ Mermaid
+ Cytoscape
+
+
+
+
+
+
+ Experimental
+ unstable surfaces — defaults may change between versions
+
+
+
+ Coverage intelligence
+ show or hide the coverage-checks surface
+
+
+
+
+
+
+
+
+ PR scope lineage
+ show or hide the PR-scope lineage mini-DAG
+
+
+
+
+
+
+
+
+
+
+ Models
+ Macros
+ Project
+
+
+ This manifest can't be visualized
+ node: —
+
+ Re-run with dbt compile or dbt run —
+ dbt parse does not produce compiled SQL.
+
+
+
+
+
+
+
+ 2 modified models · 1 connector model — modified emphasized, connectors quiet, context dimmed, deleted as ghosts. Click a node to select it below.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Model SQLraw, with Jinja
+
+
+
+
+
+
+
+
+ Model YAMLauthored schema entry
+
+
+
+
+
+
+
+ Recursive CTE present; recursive arm omitted from DAG (v0.1 limit).
+
+
+
+
+
+
+
+
+
+ node role
+
+
+
+ edge type
+
+
+
+
+
+
+ Node detail
+ ✕
+
+
+
+
+
+
+
+
+
+
+
+ Unit test
+
+
+
+ Updated only
+ All tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/diff-showcase-report.html b/examples/diff-showcase-report.html
index ca7485ac..7abbeed2 100644
--- a/examples/diff-showcase-report.html
+++ b/examples/diff-showcase-report.html
@@ -2256,6 +2256,66 @@
color: var(--text-muted); overflow-wrap: anywhere; margin: 0.5rem 0;
}
+/* PR review comments (cute-dbt#419 / #420 / #421 / #422, epic #353).
+ The whole surface is JS-rendered from DATA.pr_comments; these rules
+ dress the inline threads, the top count button, and the per-model count
+ chip. No `overflow:hidden` ancestor clips the focusable-bubble tooltip
+ (the .model-comment-count-chip reuses the shared .expect-tooltip family,
+ whose bubble is position:absolute over a position:relative trigger). */
+.pr-comments-count {
+ display: inline-flex; align-items: center; gap: 0.4rem;
+ margin: 0.6rem 0 0; padding: 0.35rem 0.7rem;
+ border: 1px solid var(--border, #ccc); border-radius: var(--radius-sm, 6px);
+ background: var(--surface-2, rgba(0,0,0,0.03)); color: var(--text, inherit);
+ font: inherit; font-size: 0.92em; cursor: pointer;
+}
+.pr-comments-count:hover { background: var(--surface-3, rgba(0,0,0,0.06)); }
+.pr-comments-count:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.pr-comments-count-label { font-weight: 600; }
+.pr-comments-count[hidden] { display: none; }
+
+.model-comments-row { margin: 0.3rem 0; }
+.model-comments-row[hidden] { display: none; }
+.model-comment-count-chip {
+ border-bottom: none; padding: 0.15rem 0.5rem;
+ border: 1px solid var(--border, #ccc); border-radius: 999px;
+ background: var(--surface-2, rgba(0,0,0,0.03)); cursor: help;
+}
+.model-comment-bubble { text-align: left; }
+.model-comment-bubble-row { display: block; white-space: nowrap; padding: 0.05rem 0; }
+
+/* Inline thread cards, injected after the anchored Model-SQL diff line. */
+.pr-comment-thread {
+ display: block; margin: 0.3rem 0 0.5rem; padding: 0.5rem 0.7rem;
+ border: 1px solid var(--border, #ccc);
+ border-left: 3px solid var(--accent);
+ border-radius: var(--radius-sm, 6px);
+ background: var(--surface-1, rgba(0,0,0,0.02));
+ font-size: 0.9em; white-space: normal;
+}
+.pr-comment-resolved { border-left-color: var(--ok, #2e7d32); opacity: 0.85; }
+.pr-comment-outdated { border-left-color: var(--warn, #b45c0c); }
+.pr-comment-thread-head {
+ display: flex; align-items: center; gap: 0.4rem;
+ margin-bottom: 0.35rem; color: var(--text-muted);
+}
+.pr-comment-anchor { font-weight: 600; }
+.pr-comment-badge {
+ font-size: 0.78em; padding: 0.05rem 0.4rem; border-radius: 999px;
+ border: 1px solid currentColor;
+}
+.pr-comment-badge-resolved { color: var(--ok, #2e7d32); }
+.pr-comment-badge-outdated { color: var(--warn, #b45c0c); }
+.pr-comment-collapse > summary { cursor: pointer; color: var(--text-muted); }
+.pr-comment { margin: 0.25rem 0; }
+.pr-comment-author { font-weight: 600; color: var(--accent); }
+.pr-comment-text { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 0.1rem; }
+.pr-comment-tail {
+ margin: 0.6rem 0; padding: 0.5rem 0.7rem;
+ border: 1px dashed var(--border, #ccc); border-radius: var(--radius-sm, 6px);
+}
+.pr-comment-tail-label { margin: 0 0 0.4rem; color: var(--text-muted); font-style: italic; }
+
/* end of cute-dbt report chassis v1 (cute-dbt#177) */
@@ -2273,6 +2333,9 @@ cute-dbt report
no unit-test definitions changed in this diff
+
+
+
@@ -2525,6 +2588,8 @@ PR scope lineage
+
+
@@ -6859,6 +6924,10 @@ Project definition changed
// cute-dbt#416 — the node-less REMOVED models summary renders once at
// boot (the set is fixed for the report; no per-model dependency).
renderRemovedModels();
+ // cute-dbt#420 — the top-of-report PR review-comment count button renders
+ // once at boot (the total is fixed for the report). No-op when
+ // DATA.pr_comments is absent (off-gate / no PR context / no comments).
+ renderPrCommentsCountButton();
renderModelSelector();
renderTestSelector();
renderForSelectedModel();
@@ -7290,6 +7359,235 @@ Project definition changed
$row.prop("hidden", false);
}
+ // ---- PR review comments (#419 / #420 / #421 / #422, epic #353) -------
+ //
+ // The whole comment surface is CLIENT-RENDERED from DATA.pr_comments (the
+ // pr_dag / seed_cards precedent) — the Rust side already ANCHORED each
+ // review thread onto a rendered diff line (the shipped #418 anchoring) and
+ // grouped the threads per model, so the JS only PLACES them: the top count
+ // button (#420), the per-model count tooltip (#421), and the inline thread
+ // at its Model-SQL diff line (#419). Absent the `pr-comments` experiment /
+ // no PR context / no comments, DATA.pr_comments is omitted and every
+ // function here no-ops — keeping every default golden byte-identical.
+
+ // The per-model comment bucket for model `m`, matched on the model's
+ // project-relative file path (`m.path` — the join key BOTH the model
+ // payload and the bucket carry; the model payload does NOT carry the node
+ // id). Null when the model carries no anchored comments.
+ function prCommentBucketFor(m) {
+ var pc = DATA && DATA.pr_comments;
+ if (!pc || !pc.by_model || !m || !m.path) { return null; }
+ for (var i = 0; i < pc.by_model.length; i++) {
+ if (pc.by_model[i].model_path === m.path) { return pc.by_model[i]; }
+ }
+ return null;
+ }
+
+ // Find the model NAME owning a comment bucket (so navigation can select it
+ // in the , which keys on name). Matches on the bucket's
+ // model_path. Null when the bucket's model is not a current dropdown model.
+ function modelNameForBucket(bucket) {
+ for (var i = 0; i < DATA.models.length; i++) {
+ if (DATA.models[i].path === bucket.model_path) { return DATA.models[i].name; }
+ }
+ return null;
+ }
+
+ // #420 — the top-of-report total-count navigation button. Fills the
+ // always-present static .pr-comments-count container and unhides it;
+ // clicking navigates to the FIRST anchored thread (selects its model +
+ // scrolls to the anchored Model-SQL diff line). Rendered ONCE at boot (the
+ // total is fixed for the report). Absent comments ⇒ the container stays
+ // hidden (byte-identity-safe: it is identical empty markup in every report).
+ function renderPrCommentsCountButton() {
+ var $btn = $('[data-testid="pr-comments-count"]');
+ if (!$btn.length) { return; }
+ var pc = DATA && DATA.pr_comments;
+ var total = (pc && typeof pc.total === "number") ? pc.total : 0;
+ if (!pc || total < 1) { $btn.prop("hidden", true).empty(); return; }
+ var label = total === 1 ? "1 PR review comment" : total + " PR review comments";
+ $btn.empty()
+ .append($("").addClass("pr-comments-count-icon").attr("aria-hidden", "true").text("💬"))
+ .append($("").addClass("pr-comments-count-label").text(label))
+ .attr("aria-label", label + " — jump to the first comment")
+ .attr("data-total", String(total))
+ .prop("hidden", false);
+ // The first navigable target: the first thread of the first model bucket
+ // that maps to a current model (skip buckets whose model is not in the
+ // dropdown). Falls back to no-op navigation if none is anchorable.
+ $btn.off("click.cuteComments").on("click.cuteComments", function () {
+ navigateToFirstComment();
+ });
+ }
+
+ // Navigate to the first anchored review thread: select its model in the
+ // dropdown (selectModelByName → renderForSelectedModel re-renders the
+ // Model-SQL diff and re-injects the threads), then scroll to that thread's
+ // anchored diff line. In-page only (no fetch — the zero-egress contract).
+ function navigateToFirstComment() {
+ var pc = DATA && DATA.pr_comments;
+ if (!pc || !pc.by_model) { return; }
+ for (var i = 0; i < pc.by_model.length; i++) {
+ var bucket = pc.by_model[i];
+ var name = modelNameForBucket(bucket);
+ if (!name) { continue; }
+ selectModelByName(name);
+ var thread = bucket.threads && bucket.threads[0];
+ // Scroll to the injected thread once the Model-SQL diff has re-rendered.
+ scrollToThread(thread);
+ return;
+ }
+ }
+
+ // Scroll the injected comment thread (or its anchored diff line) into view.
+ // Defers a tick so the renderForSelectedModel re-render + injection settle.
+ function scrollToThread(thread) {
+ setTimeout(function () {
+ var target = thread && thread.__key
+ ? document.querySelector('.pr-comment-thread[data-thread-key="' + thread.__key + '"]')
+ : null;
+ if (!target) {
+ target = document.querySelector(".model-sql .pr-comment-thread");
+ }
+ if (target && target.scrollIntoView) {
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ }, 60);
+ }
+
+ // #421 — the per-model comment count, shown via the FOCUSABLE-BUBBLE
+ // tooltip (.expect-tooltip + .expect-tooltip-bubble): a focusable
+ // whose CSS bubble paints on :hover AND :focus — NEVER a native `title`
+ // (hover-delayed, keyboard-unreachable, touch-invisible). aria-label
+ // carries the count for AT; the bubble is aria-hidden (never announced
+ // twice). Hidden when the selected model has no anchored comments.
+ function renderModelCommentCount(m) {
+ var $row = $('[data-testid="model-comments"]');
+ if (!$row.length) { return; }
+ $row.empty();
+ var bucket = prCommentBucketFor(m);
+ if (!bucket || !bucket.count) { $row.prop("hidden", true); return; }
+ var n = bucket.count;
+ var word = n === 1 ? "comment" : "comments";
+ var aria = n + " review " + word + " on this model";
+ var $btn = $("").attr("type", "button")
+ .addClass("expect-tooltip model-comment-count-chip")
+ .attr("data-testid", "model-comment-count")
+ .attr("data-count", String(n))
+ .attr("aria-label", aria)
+ .text("💬 " + n + " " + word);
+ // The bubble: a per-thread summary (author of the first comment + a
+ // resolved/outdated marker), revealed on hover/focus.
+ var $bubble = $("").addClass("expect-tooltip-bubble model-comment-bubble")
+ .attr("data-testid", "model-comment-bubble").attr("aria-hidden", "true");
+ bucket.threads.forEach(function (t) {
+ var who = (t.comments && t.comments[0] && t.comments[0].author) || "ghost";
+ var where = t.outdated ? "outdated"
+ : (t.line != null ? "line " + t.line : "—");
+ var stateMark = t.resolved ? " · resolved" : "";
+ $bubble.append(
+ $("").addClass("model-comment-bubble-row")
+ .text("@" + who + " · " + where + stateMark)
+ );
+ });
+ $btn.append($bubble);
+ $row.append($btn).prop("hidden", false);
+ }
+
+ // #419 — inject each anchored review thread inline at its Model-SQL diff
+ // line. Runs AFTER renderModelSql has rebuilt the .model-sql diff for the
+ // selected model. A thread RESOLVED to a live line (Right → data-newline,
+ // Left → data-oldline) is inserted right after that diff line; an OUTDATED
+ // thread (no live line) — and any thread whose anchored line is not in the
+ // rendered diff — is appended to a per-model "outdated / unplaced comments"
+ // tail so reviewer context is never lost (the never-drop posture).
+ function anchorModelCommentThreads(m) {
+ var $section = $(".model-sql");
+ // Always clear a prior model's injected threads first (the
+ // renderModelSql rebuild contract — never leak across a model switch).
+ $section.find(".pr-comment-thread, .pr-comment-tail").remove();
+ var bucket = prCommentBucketFor(m);
+ if (!bucket || !bucket.threads.length) { return; }
+ var $diff = $section.find(".sql-diff-view");
+ var unplaced = [];
+ bucket.threads.forEach(function (t, idx) {
+ // Give each thread a stable per-model key so the navigation scroll can
+ // target it (and the headless test can assert it).
+ t.__key = (m.path || m.name || "model") + "#" + idx;
+ var $line = null;
+ if (!t.outdated && t.line != null && $diff.length) {
+ var attr = (t.side === "Left") ? "data-oldline" : "data-newline";
+ $line = $diff.find('.diff-line[' + attr + '="' + t.line + '"]').first();
+ }
+ if ($line && $line.length) {
+ $line.after(buildCommentThread(t));
+ } else {
+ unplaced.push(t);
+ }
+ });
+ if (unplaced.length) {
+ var $tail = $("").addClass("pr-comment-tail").attr("data-testid", "pr-comment-tail");
+ $tail.append(
+ $("
").addClass("pr-comment-tail-label")
+ .text(unplaced.length === 1
+ ? "1 comment refers to a line that changed or is not shown:"
+ : unplaced.length + " comments refer to lines that changed or are not shown:")
+ );
+ unplaced.forEach(function (t) { $tail.append(buildCommentThread(t)); });
+ // Place the tail after the diff (or the section wrap) so it is visible.
+ var $wrap = $section.find(".model-sql-wrap");
+ ($wrap.length ? $wrap : $section).append($tail);
+ }
+ }
+
+ // Build one review thread's DOM — GitHub-style: a header (anchor/line +
+ // resolved/outdated badges) over the comment rows (author + body). All
+ // strings are set via .text()/textContent (NEVER innerHTML) so a
+ // manifest/PR-derived body is drawn as text, never parsed as HTML.
+ function buildCommentThread(t) {
+ var $thread = $("
").addClass("pr-comment-thread")
+ .attr("data-testid", "pr-comment-thread")
+ .attr("data-thread-key", t.__key || "");
+ if (t.resolved) { $thread.addClass("pr-comment-resolved"); }
+ if (t.outdated) { $thread.addClass("pr-comment-outdated"); }
+ var $head = $("
").addClass("pr-comment-thread-head");
+ var anchorText = t.outdated
+ ? (t.original_line != null ? "outdated · was line " + t.original_line : "outdated")
+ : (t.line != null ? "line " + t.line : "comment");
+ $head.append($("
").addClass("pr-comment-anchor").text(anchorText));
+ if (t.resolved) {
+ $head.append($("").addClass("pr-comment-badge pr-comment-badge-resolved").text("resolved"));
+ }
+ if (t.outdated) {
+ $head.append($("").addClass("pr-comment-badge pr-comment-badge-outdated").text("outdated"));
+ }
+ $thread.append($head);
+ // Resolved threads collapse: the body lives in a , closed by
+ // default, so a resolved conversation is quiet but still inspectable.
+ var $body;
+ if (t.resolved) {
+ var $det = $("").addClass("pr-comment-collapse");
+ $det.append($("").text(
+ (t.comments ? t.comments.length : 0) === 1
+ ? "1 comment (resolved)"
+ : (t.comments ? t.comments.length : 0) + " comments (resolved)"
+ ));
+ $body = $("").addClass("pr-comment-body");
+ $det.append($body);
+ $thread.append($det);
+ } else {
+ $body = $("
").addClass("pr-comment-body");
+ $thread.append($body);
+ }
+ (t.comments || []).forEach(function (c) {
+ var $c = $("
").addClass("pr-comment");
+ $c.append($("
").addClass("pr-comment-author").text("@" + (c.author || "ghost")));
+ $c.append($("").addClass("pr-comment-text").text(c.body || ""));
+ $body.append($c);
+ });
+ return $thread;
+ }
+
function renderModelAttribution(m) {
var $row = $('[data-testid="model-attribution"]').empty();
var list = (m && m.config_attributions) || [];
@@ -7338,8 +7636,15 @@
Project definition changed
closeNodeShelf();
renderModelAxes(m);
renderModelAttribution(m);
+ // cute-dbt#421 — the per-model PR review-comment count tooltip (focusable
+ // bubble). No-op when the selected model carries no anchored comments.
+ renderModelCommentCount(m);
renderTestDetails(t);
renderModelSql(m);
+ // cute-dbt#419 — inject the anchored review threads inline at their
+ // Model-SQL diff lines (and an outdated/unplaced tail). MUST run AFTER
+ // renderModelSql rebuilds the diff for this model. No-op without comments.
+ anchorModelCommentThreads(m);
// cute-dbt#247 — the Model YAML section (peer of Model SQL): the
// model's authored schema-file `models:` entry, or its truthful
// degrade placeholder.
@@ -10092,7 +10397,15 @@ Project definition changed
: ln.kind === "removed" ? "diff-removed"
: "diff-context";
var sigil = ln.kind === "added" ? "+" : ln.kind === "removed" ? "-" : " ";
+ // cute-dbt#419 — carry the old/new line numbers as data attributes so
+ // the PR-comment injector (anchorModelCommentThreads) can find the exact
+ // rendered diff line a review thread anchors to (Right side → new line,
+ // Left side → old line). Decorative positional metadata only (the
+ // .diff-gutter precedent); absent the pr-comments surface they are inert.
+ var oAttr = (num && num.o != null) ? ' data-oldline="' + num.o + '"' : "";
+ var nAttr = (num && num.n != null) ? ' data-newline="' + num.n + '"' : "";
return '
+
+
@@ -6242,6 +6307,10 @@
Expected
// cute-dbt#416 — the node-less REMOVED models summary renders once at
// boot (the set is fixed for the report; no per-model dependency).
renderRemovedModels();
+ // cute-dbt#420 — the top-of-report PR review-comment count button renders
+ // once at boot (the total is fixed for the report). No-op when
+ // DATA.pr_comments is absent (off-gate / no PR context / no comments).
+ renderPrCommentsCountButton();
renderModelSelector();
renderTestSelector();
renderForSelectedModel();
@@ -6673,6 +6742,235 @@
Expected
$row.prop("hidden", false);
}
+ // ---- PR review comments (#419 / #420 / #421 / #422, epic #353) -------
+ //
+ // The whole comment surface is CLIENT-RENDERED from DATA.pr_comments (the
+ // pr_dag / seed_cards precedent) — the Rust side already ANCHORED each
+ // review thread onto a rendered diff line (the shipped #418 anchoring) and
+ // grouped the threads per model, so the JS only PLACES them: the top count
+ // button (#420), the per-model count tooltip (#421), and the inline thread
+ // at its Model-SQL diff line (#419). Absent the `pr-comments` experiment /
+ // no PR context / no comments, DATA.pr_comments is omitted and every
+ // function here no-ops — keeping every default golden byte-identical.
+
+ // The per-model comment bucket for model `m`, matched on the model's
+ // project-relative file path (`m.path` — the join key BOTH the model
+ // payload and the bucket carry; the model payload does NOT carry the node
+ // id). Null when the model carries no anchored comments.
+ function prCommentBucketFor(m) {
+ var pc = DATA && DATA.pr_comments;
+ if (!pc || !pc.by_model || !m || !m.path) { return null; }
+ for (var i = 0; i < pc.by_model.length; i++) {
+ if (pc.by_model[i].model_path === m.path) { return pc.by_model[i]; }
+ }
+ return null;
+ }
+
+ // Find the model NAME owning a comment bucket (so navigation can select it
+ // in the
, which keys on name). Matches on the bucket's
+ // model_path. Null when the bucket's model is not a current dropdown model.
+ function modelNameForBucket(bucket) {
+ for (var i = 0; i < DATA.models.length; i++) {
+ if (DATA.models[i].path === bucket.model_path) { return DATA.models[i].name; }
+ }
+ return null;
+ }
+
+ // #420 — the top-of-report total-count navigation button. Fills the
+ // always-present static .pr-comments-count container and unhides it;
+ // clicking navigates to the FIRST anchored thread (selects its model +
+ // scrolls to the anchored Model-SQL diff line). Rendered ONCE at boot (the
+ // total is fixed for the report). Absent comments ⇒ the container stays
+ // hidden (byte-identity-safe: it is identical empty markup in every report).
+ function renderPrCommentsCountButton() {
+ var $btn = $('[data-testid="pr-comments-count"]');
+ if (!$btn.length) { return; }
+ var pc = DATA && DATA.pr_comments;
+ var total = (pc && typeof pc.total === "number") ? pc.total : 0;
+ if (!pc || total < 1) { $btn.prop("hidden", true).empty(); return; }
+ var label = total === 1 ? "1 PR review comment" : total + " PR review comments";
+ $btn.empty()
+ .append($("").addClass("pr-comments-count-icon").attr("aria-hidden", "true").text("💬"))
+ .append($("").addClass("pr-comments-count-label").text(label))
+ .attr("aria-label", label + " — jump to the first comment")
+ .attr("data-total", String(total))
+ .prop("hidden", false);
+ // The first navigable target: the first thread of the first model bucket
+ // that maps to a current model (skip buckets whose model is not in the
+ // dropdown). Falls back to no-op navigation if none is anchorable.
+ $btn.off("click.cuteComments").on("click.cuteComments", function () {
+ navigateToFirstComment();
+ });
+ }
+
+ // Navigate to the first anchored review thread: select its model in the
+ // dropdown (selectModelByName → renderForSelectedModel re-renders the
+ // Model-SQL diff and re-injects the threads), then scroll to that thread's
+ // anchored diff line. In-page only (no fetch — the zero-egress contract).
+ function navigateToFirstComment() {
+ var pc = DATA && DATA.pr_comments;
+ if (!pc || !pc.by_model) { return; }
+ for (var i = 0; i < pc.by_model.length; i++) {
+ var bucket = pc.by_model[i];
+ var name = modelNameForBucket(bucket);
+ if (!name) { continue; }
+ selectModelByName(name);
+ var thread = bucket.threads && bucket.threads[0];
+ // Scroll to the injected thread once the Model-SQL diff has re-rendered.
+ scrollToThread(thread);
+ return;
+ }
+ }
+
+ // Scroll the injected comment thread (or its anchored diff line) into view.
+ // Defers a tick so the renderForSelectedModel re-render + injection settle.
+ function scrollToThread(thread) {
+ setTimeout(function () {
+ var target = thread && thread.__key
+ ? document.querySelector('.pr-comment-thread[data-thread-key="' + thread.__key + '"]')
+ : null;
+ if (!target) {
+ target = document.querySelector(".model-sql .pr-comment-thread");
+ }
+ if (target && target.scrollIntoView) {
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ }, 60);
+ }
+
+ // #421 — the per-model comment count, shown via the FOCUSABLE-BUBBLE
+ // tooltip (.expect-tooltip + .expect-tooltip-bubble): a focusable
+ // whose CSS bubble paints on :hover AND :focus — NEVER a native `title`
+ // (hover-delayed, keyboard-unreachable, touch-invisible). aria-label
+ // carries the count for AT; the bubble is aria-hidden (never announced
+ // twice). Hidden when the selected model has no anchored comments.
+ function renderModelCommentCount(m) {
+ var $row = $('[data-testid="model-comments"]');
+ if (!$row.length) { return; }
+ $row.empty();
+ var bucket = prCommentBucketFor(m);
+ if (!bucket || !bucket.count) { $row.prop("hidden", true); return; }
+ var n = bucket.count;
+ var word = n === 1 ? "comment" : "comments";
+ var aria = n + " review " + word + " on this model";
+ var $btn = $("").attr("type", "button")
+ .addClass("expect-tooltip model-comment-count-chip")
+ .attr("data-testid", "model-comment-count")
+ .attr("data-count", String(n))
+ .attr("aria-label", aria)
+ .text("💬 " + n + " " + word);
+ // The bubble: a per-thread summary (author of the first comment + a
+ // resolved/outdated marker), revealed on hover/focus.
+ var $bubble = $("").addClass("expect-tooltip-bubble model-comment-bubble")
+ .attr("data-testid", "model-comment-bubble").attr("aria-hidden", "true");
+ bucket.threads.forEach(function (t) {
+ var who = (t.comments && t.comments[0] && t.comments[0].author) || "ghost";
+ var where = t.outdated ? "outdated"
+ : (t.line != null ? "line " + t.line : "—");
+ var stateMark = t.resolved ? " · resolved" : "";
+ $bubble.append(
+ $("").addClass("model-comment-bubble-row")
+ .text("@" + who + " · " + where + stateMark)
+ );
+ });
+ $btn.append($bubble);
+ $row.append($btn).prop("hidden", false);
+ }
+
+ // #419 — inject each anchored review thread inline at its Model-SQL diff
+ // line. Runs AFTER renderModelSql has rebuilt the .model-sql diff for the
+ // selected model. A thread RESOLVED to a live line (Right → data-newline,
+ // Left → data-oldline) is inserted right after that diff line; an OUTDATED
+ // thread (no live line) — and any thread whose anchored line is not in the
+ // rendered diff — is appended to a per-model "outdated / unplaced comments"
+ // tail so reviewer context is never lost (the never-drop posture).
+ function anchorModelCommentThreads(m) {
+ var $section = $(".model-sql");
+ // Always clear a prior model's injected threads first (the
+ // renderModelSql rebuild contract — never leak across a model switch).
+ $section.find(".pr-comment-thread, .pr-comment-tail").remove();
+ var bucket = prCommentBucketFor(m);
+ if (!bucket || !bucket.threads.length) { return; }
+ var $diff = $section.find(".sql-diff-view");
+ var unplaced = [];
+ bucket.threads.forEach(function (t, idx) {
+ // Give each thread a stable per-model key so the navigation scroll can
+ // target it (and the headless test can assert it).
+ t.__key = (m.path || m.name || "model") + "#" + idx;
+ var $line = null;
+ if (!t.outdated && t.line != null && $diff.length) {
+ var attr = (t.side === "Left") ? "data-oldline" : "data-newline";
+ $line = $diff.find('.diff-line[' + attr + '="' + t.line + '"]').first();
+ }
+ if ($line && $line.length) {
+ $line.after(buildCommentThread(t));
+ } else {
+ unplaced.push(t);
+ }
+ });
+ if (unplaced.length) {
+ var $tail = $("").addClass("pr-comment-tail").attr("data-testid", "pr-comment-tail");
+ $tail.append(
+ $("
").addClass("pr-comment-tail-label")
+ .text(unplaced.length === 1
+ ? "1 comment refers to a line that changed or is not shown:"
+ : unplaced.length + " comments refer to lines that changed or are not shown:")
+ );
+ unplaced.forEach(function (t) { $tail.append(buildCommentThread(t)); });
+ // Place the tail after the diff (or the section wrap) so it is visible.
+ var $wrap = $section.find(".model-sql-wrap");
+ ($wrap.length ? $wrap : $section).append($tail);
+ }
+ }
+
+ // Build one review thread's DOM — GitHub-style: a header (anchor/line +
+ // resolved/outdated badges) over the comment rows (author + body). All
+ // strings are set via .text()/textContent (NEVER innerHTML) so a
+ // manifest/PR-derived body is drawn as text, never parsed as HTML.
+ function buildCommentThread(t) {
+ var $thread = $("
").addClass("pr-comment-thread")
+ .attr("data-testid", "pr-comment-thread")
+ .attr("data-thread-key", t.__key || "");
+ if (t.resolved) { $thread.addClass("pr-comment-resolved"); }
+ if (t.outdated) { $thread.addClass("pr-comment-outdated"); }
+ var $head = $("
").addClass("pr-comment-thread-head");
+ var anchorText = t.outdated
+ ? (t.original_line != null ? "outdated · was line " + t.original_line : "outdated")
+ : (t.line != null ? "line " + t.line : "comment");
+ $head.append($("
").addClass("pr-comment-anchor").text(anchorText));
+ if (t.resolved) {
+ $head.append($("").addClass("pr-comment-badge pr-comment-badge-resolved").text("resolved"));
+ }
+ if (t.outdated) {
+ $head.append($("").addClass("pr-comment-badge pr-comment-badge-outdated").text("outdated"));
+ }
+ $thread.append($head);
+ // Resolved threads collapse: the body lives in a , closed by
+ // default, so a resolved conversation is quiet but still inspectable.
+ var $body;
+ if (t.resolved) {
+ var $det = $("").addClass("pr-comment-collapse");
+ $det.append($("").text(
+ (t.comments ? t.comments.length : 0) === 1
+ ? "1 comment (resolved)"
+ : (t.comments ? t.comments.length : 0) + " comments (resolved)"
+ ));
+ $body = $("").addClass("pr-comment-body");
+ $det.append($body);
+ $thread.append($det);
+ } else {
+ $body = $("
").addClass("pr-comment-body");
+ $thread.append($body);
+ }
+ (t.comments || []).forEach(function (c) {
+ var $c = $("
").addClass("pr-comment");
+ $c.append($("
").addClass("pr-comment-author").text("@" + (c.author || "ghost")));
+ $c.append($("").addClass("pr-comment-text").text(c.body || ""));
+ $body.append($c);
+ });
+ return $thread;
+ }
+
function renderModelAttribution(m) {
var $row = $('[data-testid="model-attribution"]').empty();
var list = (m && m.config_attributions) || [];
@@ -6721,8 +7019,15 @@
Expected
closeNodeShelf();
renderModelAxes(m);
renderModelAttribution(m);
+ // cute-dbt#421 — the per-model PR review-comment count tooltip (focusable
+ // bubble). No-op when the selected model carries no anchored comments.
+ renderModelCommentCount(m);
renderTestDetails(t);
renderModelSql(m);
+ // cute-dbt#419 — inject the anchored review threads inline at their
+ // Model-SQL diff lines (and an outdated/unplaced tail). MUST run AFTER
+ // renderModelSql rebuilds the diff for this model. No-op without comments.
+ anchorModelCommentThreads(m);
// cute-dbt#247 — the Model YAML section (peer of Model SQL): the
// model's authored schema-file `models:` entry, or its truthful
// degrade placeholder.
@@ -9475,7 +9780,15 @@ Expected
: ln.kind === "removed" ? "diff-removed"
: "diff-context";
var sigil = ln.kind === "added" ? "+" : ln.kind === "removed" ? "-" : " ";
+ // cute-dbt#419 — carry the old/new line numbers as data attributes so
+ // the PR-comment injector (anchorModelCommentThreads) can find the exact
+ // rendered diff line a review thread anchors to (Right side → new line,
+ // Left side → old line). Decorative positional metadata only (the
+ // .diff-gutter precedent); absent the pr-comments surface they are inert.
+ var oAttr = (num && num.o != null) ? ' data-oldline="' + num.o + '"' : "";
+ var nAttr = (num && num.n != null) ? ' data-newline="' + num.n + '"' : "";
return '
+
+
@@ -6541,6 +6606,10 @@
Macro changed , which keys on name). Matches on the bucket's
+ // model_path. Null when the bucket's model is not a current dropdown model.
+ function modelNameForBucket(bucket) {
+ for (var i = 0; i < DATA.models.length; i++) {
+ if (DATA.models[i].path === bucket.model_path) { return DATA.models[i].name; }
+ }
+ return null;
+ }
+
+ // #420 — the top-of-report total-count navigation button. Fills the
+ // always-present static .pr-comments-count container and unhides it;
+ // clicking navigates to the FIRST anchored thread (selects its model +
+ // scrolls to the anchored Model-SQL diff line). Rendered ONCE at boot (the
+ // total is fixed for the report). Absent comments ⇒ the container stays
+ // hidden (byte-identity-safe: it is identical empty markup in every report).
+ function renderPrCommentsCountButton() {
+ var $btn = $('[data-testid="pr-comments-count"]');
+ if (!$btn.length) { return; }
+ var pc = DATA && DATA.pr_comments;
+ var total = (pc && typeof pc.total === "number") ? pc.total : 0;
+ if (!pc || total < 1) { $btn.prop("hidden", true).empty(); return; }
+ var label = total === 1 ? "1 PR review comment" : total + " PR review comments";
+ $btn.empty()
+ .append($("").addClass("pr-comments-count-icon").attr("aria-hidden", "true").text("💬"))
+ .append($("").addClass("pr-comments-count-label").text(label))
+ .attr("aria-label", label + " — jump to the first comment")
+ .attr("data-total", String(total))
+ .prop("hidden", false);
+ // The first navigable target: the first thread of the first model bucket
+ // that maps to a current model (skip buckets whose model is not in the
+ // dropdown). Falls back to no-op navigation if none is anchorable.
+ $btn.off("click.cuteComments").on("click.cuteComments", function () {
+ navigateToFirstComment();
+ });
+ }
+
+ // Navigate to the first anchored review thread: select its model in the
+ // dropdown (selectModelByName → renderForSelectedModel re-renders the
+ // Model-SQL diff and re-injects the threads), then scroll to that thread's
+ // anchored diff line. In-page only (no fetch — the zero-egress contract).
+ function navigateToFirstComment() {
+ var pc = DATA && DATA.pr_comments;
+ if (!pc || !pc.by_model) { return; }
+ for (var i = 0; i < pc.by_model.length; i++) {
+ var bucket = pc.by_model[i];
+ var name = modelNameForBucket(bucket);
+ if (!name) { continue; }
+ selectModelByName(name);
+ var thread = bucket.threads && bucket.threads[0];
+ // Scroll to the injected thread once the Model-SQL diff has re-rendered.
+ scrollToThread(thread);
+ return;
+ }
+ }
+
+ // Scroll the injected comment thread (or its anchored diff line) into view.
+ // Defers a tick so the renderForSelectedModel re-render + injection settle.
+ function scrollToThread(thread) {
+ setTimeout(function () {
+ var target = thread && thread.__key
+ ? document.querySelector('.pr-comment-thread[data-thread-key="' + thread.__key + '"]')
+ : null;
+ if (!target) {
+ target = document.querySelector(".model-sql .pr-comment-thread");
+ }
+ if (target && target.scrollIntoView) {
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ }, 60);
+ }
+
+ // #421 — the per-model comment count, shown via the FOCUSABLE-BUBBLE
+ // tooltip (.expect-tooltip + .expect-tooltip-bubble): a focusable
+ // whose CSS bubble paints on :hover AND :focus — NEVER a native `title`
+ // (hover-delayed, keyboard-unreachable, touch-invisible). aria-label
+ // carries the count for AT; the bubble is aria-hidden (never announced
+ // twice). Hidden when the selected model has no anchored comments.
+ function renderModelCommentCount(m) {
+ var $row = $('[data-testid="model-comments"]');
+ if (!$row.length) { return; }
+ $row.empty();
+ var bucket = prCommentBucketFor(m);
+ if (!bucket || !bucket.count) { $row.prop("hidden", true); return; }
+ var n = bucket.count;
+ var word = n === 1 ? "comment" : "comments";
+ var aria = n + " review " + word + " on this model";
+ var $btn = $("").attr("type", "button")
+ .addClass("expect-tooltip model-comment-count-chip")
+ .attr("data-testid", "model-comment-count")
+ .attr("data-count", String(n))
+ .attr("aria-label", aria)
+ .text("💬 " + n + " " + word);
+ // The bubble: a per-thread summary (author of the first comment + a
+ // resolved/outdated marker), revealed on hover/focus.
+ var $bubble = $("").addClass("expect-tooltip-bubble model-comment-bubble")
+ .attr("data-testid", "model-comment-bubble").attr("aria-hidden", "true");
+ bucket.threads.forEach(function (t) {
+ var who = (t.comments && t.comments[0] && t.comments[0].author) || "ghost";
+ var where = t.outdated ? "outdated"
+ : (t.line != null ? "line " + t.line : "—");
+ var stateMark = t.resolved ? " · resolved" : "";
+ $bubble.append(
+ $("").addClass("model-comment-bubble-row")
+ .text("@" + who + " · " + where + stateMark)
+ );
+ });
+ $btn.append($bubble);
+ $row.append($btn).prop("hidden", false);
+ }
+
+ // #419 — inject each anchored review thread inline at its Model-SQL diff
+ // line. Runs AFTER renderModelSql has rebuilt the .model-sql diff for the
+ // selected model. A thread RESOLVED to a live line (Right → data-newline,
+ // Left → data-oldline) is inserted right after that diff line; an OUTDATED
+ // thread (no live line) — and any thread whose anchored line is not in the
+ // rendered diff — is appended to a per-model "outdated / unplaced comments"
+ // tail so reviewer context is never lost (the never-drop posture).
+ function anchorModelCommentThreads(m) {
+ var $section = $(".model-sql");
+ // Always clear a prior model's injected threads first (the
+ // renderModelSql rebuild contract — never leak across a model switch).
+ $section.find(".pr-comment-thread, .pr-comment-tail").remove();
+ var bucket = prCommentBucketFor(m);
+ if (!bucket || !bucket.threads.length) { return; }
+ var $diff = $section.find(".sql-diff-view");
+ var unplaced = [];
+ bucket.threads.forEach(function (t, idx) {
+ // Give each thread a stable per-model key so the navigation scroll can
+ // target it (and the headless test can assert it).
+ t.__key = (m.path || m.name || "model") + "#" + idx;
+ var $line = null;
+ if (!t.outdated && t.line != null && $diff.length) {
+ var attr = (t.side === "Left") ? "data-oldline" : "data-newline";
+ $line = $diff.find('.diff-line[' + attr + '="' + t.line + '"]').first();
+ }
+ if ($line && $line.length) {
+ $line.after(buildCommentThread(t));
+ } else {
+ unplaced.push(t);
+ }
+ });
+ if (unplaced.length) {
+ var $tail = $("").addClass("pr-comment-tail").attr("data-testid", "pr-comment-tail");
+ $tail.append(
+ $("
").addClass("pr-comment-tail-label")
+ .text(unplaced.length === 1
+ ? "1 comment refers to a line that changed or is not shown:"
+ : unplaced.length + " comments refer to lines that changed or are not shown:")
+ );
+ unplaced.forEach(function (t) { $tail.append(buildCommentThread(t)); });
+ // Place the tail after the diff (or the section wrap) so it is visible.
+ var $wrap = $section.find(".model-sql-wrap");
+ ($wrap.length ? $wrap : $section).append($tail);
+ }
+ }
+
+ // Build one review thread's DOM — GitHub-style: a header (anchor/line +
+ // resolved/outdated badges) over the comment rows (author + body). All
+ // strings are set via .text()/textContent (NEVER innerHTML) so a
+ // manifest/PR-derived body is drawn as text, never parsed as HTML.
+ function buildCommentThread(t) {
+ var $thread = $("
").addClass("pr-comment-thread")
+ .attr("data-testid", "pr-comment-thread")
+ .attr("data-thread-key", t.__key || "");
+ if (t.resolved) { $thread.addClass("pr-comment-resolved"); }
+ if (t.outdated) { $thread.addClass("pr-comment-outdated"); }
+ var $head = $("
").addClass("pr-comment-thread-head");
+ var anchorText = t.outdated
+ ? (t.original_line != null ? "outdated · was line " + t.original_line : "outdated")
+ : (t.line != null ? "line " + t.line : "comment");
+ $head.append($("
").addClass("pr-comment-anchor").text(anchorText));
+ if (t.resolved) {
+ $head.append($("").addClass("pr-comment-badge pr-comment-badge-resolved").text("resolved"));
+ }
+ if (t.outdated) {
+ $head.append($("").addClass("pr-comment-badge pr-comment-badge-outdated").text("outdated"));
+ }
+ $thread.append($head);
+ // Resolved threads collapse: the body lives in a , closed by
+ // default, so a resolved conversation is quiet but still inspectable.
+ var $body;
+ if (t.resolved) {
+ var $det = $("").addClass("pr-comment-collapse");
+ $det.append($("").text(
+ (t.comments ? t.comments.length : 0) === 1
+ ? "1 comment (resolved)"
+ : (t.comments ? t.comments.length : 0) + " comments (resolved)"
+ ));
+ $body = $("").addClass("pr-comment-body");
+ $det.append($body);
+ $thread.append($det);
+ } else {
+ $body = $("
").addClass("pr-comment-body");
+ $thread.append($body);
+ }
+ (t.comments || []).forEach(function (c) {
+ var $c = $("
").addClass("pr-comment");
+ $c.append($("
").addClass("pr-comment-author").text("@" + (c.author || "ghost")));
+ $c.append($("").addClass("pr-comment-text").text(c.body || ""));
+ $body.append($c);
+ });
+ return $thread;
+ }
+
function renderModelAttribution(m) {
var $row = $('[data-testid="model-attribution"]').empty();
var list = (m && m.config_attributions) || [];
@@ -7020,8 +7318,15 @@
Macro changed '
+ diffGutter(num ? num.o : null, num ? num.n : null)
+ '' + sigil + ' '
diff --git a/examples/playground-report.html b/examples/playground-report.html
index 435e161d..34516bbc 100644
--- a/examples/playground-report.html
+++ b/examples/playground-report.html
@@ -2256,6 +2256,66 @@
color: var(--text-muted); overflow-wrap: anywhere; margin: 0.5rem 0;
}
+/* PR review comments (cute-dbt#419 / #420 / #421 / #422, epic #353).
+ The whole surface is JS-rendered from DATA.pr_comments; these rules
+ dress the inline threads, the top count button, and the per-model count
+ chip. No `overflow:hidden` ancestor clips the focusable-bubble tooltip
+ (the .model-comment-count-chip reuses the shared .expect-tooltip family,
+ whose bubble is position:absolute over a position:relative trigger). */
+.pr-comments-count {
+ display: inline-flex; align-items: center; gap: 0.4rem;
+ margin: 0.6rem 0 0; padding: 0.35rem 0.7rem;
+ border: 1px solid var(--border, #ccc); border-radius: var(--radius-sm, 6px);
+ background: var(--surface-2, rgba(0,0,0,0.03)); color: var(--text, inherit);
+ font: inherit; font-size: 0.92em; cursor: pointer;
+}
+.pr-comments-count:hover { background: var(--surface-3, rgba(0,0,0,0.06)); }
+.pr-comments-count:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.pr-comments-count-label { font-weight: 600; }
+.pr-comments-count[hidden] { display: none; }
+
+.model-comments-row { margin: 0.3rem 0; }
+.model-comments-row[hidden] { display: none; }
+.model-comment-count-chip {
+ border-bottom: none; padding: 0.15rem 0.5rem;
+ border: 1px solid var(--border, #ccc); border-radius: 999px;
+ background: var(--surface-2, rgba(0,0,0,0.03)); cursor: help;
+}
+.model-comment-bubble { text-align: left; }
+.model-comment-bubble-row { display: block; white-space: nowrap; padding: 0.05rem 0; }
+
+/* Inline thread cards, injected after the anchored Model-SQL diff line. */
+.pr-comment-thread {
+ display: block; margin: 0.3rem 0 0.5rem; padding: 0.5rem 0.7rem;
+ border: 1px solid var(--border, #ccc);
+ border-left: 3px solid var(--accent);
+ border-radius: var(--radius-sm, 6px);
+ background: var(--surface-1, rgba(0,0,0,0.02));
+ font-size: 0.9em; white-space: normal;
+}
+.pr-comment-resolved { border-left-color: var(--ok, #2e7d32); opacity: 0.85; }
+.pr-comment-outdated { border-left-color: var(--warn, #b45c0c); }
+.pr-comment-thread-head {
+ display: flex; align-items: center; gap: 0.4rem;
+ margin-bottom: 0.35rem; color: var(--text-muted);
+}
+.pr-comment-anchor { font-weight: 600; }
+.pr-comment-badge {
+ font-size: 0.78em; padding: 0.05rem 0.4rem; border-radius: 999px;
+ border: 1px solid currentColor;
+}
+.pr-comment-badge-resolved { color: var(--ok, #2e7d32); }
+.pr-comment-badge-outdated { color: var(--warn, #b45c0c); }
+.pr-comment-collapse > summary { cursor: pointer; color: var(--text-muted); }
+.pr-comment { margin: 0.25rem 0; }
+.pr-comment-author { font-weight: 600; color: var(--accent); }
+.pr-comment-text { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 0.1rem; }
+.pr-comment-tail {
+ margin: 0.6rem 0; padding: 0.5rem 0.7rem;
+ border: 1px dashed var(--border, #ccc); border-radius: var(--radius-sm, 6px);
+}
+.pr-comment-tail-label { margin: 0 0 0.4rem; color: var(--text-muted); font-style: italic; }
+
/* end of cute-dbt report chassis v1 (cute-dbt#177) */
@@ -2271,6 +2331,9 @@ cute-dbt report
Showing 6 unit tests in scope —
vs baseline manifest tests/fixtures/playground-baseline.json.
+
+
+
@@ -2387,6 +2450,8 @@ This manifest can't be visualized
+
+
@@ -6242,6 +6307,10 @@
Expected
// cute-dbt#416 — the node-less REMOVED models summary renders once at
// boot (the set is fixed for the report; no per-model dependency).
renderRemovedModels();
+ // cute-dbt#420 — the top-of-report PR review-comment count button renders
+ // once at boot (the total is fixed for the report). No-op when
+ // DATA.pr_comments is absent (off-gate / no PR context / no comments).
+ renderPrCommentsCountButton();
renderModelSelector();
renderTestSelector();
renderForSelectedModel();
@@ -6673,6 +6742,235 @@
Expected
$row.prop("hidden", false);
}
+ // ---- PR review comments (#419 / #420 / #421 / #422, epic #353) -------
+ //
+ // The whole comment surface is CLIENT-RENDERED from DATA.pr_comments (the
+ // pr_dag / seed_cards precedent) — the Rust side already ANCHORED each
+ // review thread onto a rendered diff line (the shipped #418 anchoring) and
+ // grouped the threads per model, so the JS only PLACES them: the top count
+ // button (#420), the per-model count tooltip (#421), and the inline thread
+ // at its Model-SQL diff line (#419). Absent the `pr-comments` experiment /
+ // no PR context / no comments, DATA.pr_comments is omitted and every
+ // function here no-ops — keeping every default golden byte-identical.
+
+ // The per-model comment bucket for model `m`, matched on the model's
+ // project-relative file path (`m.path` — the join key BOTH the model
+ // payload and the bucket carry; the model payload does NOT carry the node
+ // id). Null when the model carries no anchored comments.
+ function prCommentBucketFor(m) {
+ var pc = DATA && DATA.pr_comments;
+ if (!pc || !pc.by_model || !m || !m.path) { return null; }
+ for (var i = 0; i < pc.by_model.length; i++) {
+ if (pc.by_model[i].model_path === m.path) { return pc.by_model[i]; }
+ }
+ return null;
+ }
+
+ // Find the model NAME owning a comment bucket (so navigation can select it
+ // in the
, which keys on name). Matches on the bucket's
+ // model_path. Null when the bucket's model is not a current dropdown model.
+ function modelNameForBucket(bucket) {
+ for (var i = 0; i < DATA.models.length; i++) {
+ if (DATA.models[i].path === bucket.model_path) { return DATA.models[i].name; }
+ }
+ return null;
+ }
+
+ // #420 — the top-of-report total-count navigation button. Fills the
+ // always-present static .pr-comments-count container and unhides it;
+ // clicking navigates to the FIRST anchored thread (selects its model +
+ // scrolls to the anchored Model-SQL diff line). Rendered ONCE at boot (the
+ // total is fixed for the report). Absent comments ⇒ the container stays
+ // hidden (byte-identity-safe: it is identical empty markup in every report).
+ function renderPrCommentsCountButton() {
+ var $btn = $('[data-testid="pr-comments-count"]');
+ if (!$btn.length) { return; }
+ var pc = DATA && DATA.pr_comments;
+ var total = (pc && typeof pc.total === "number") ? pc.total : 0;
+ if (!pc || total < 1) { $btn.prop("hidden", true).empty(); return; }
+ var label = total === 1 ? "1 PR review comment" : total + " PR review comments";
+ $btn.empty()
+ .append($("").addClass("pr-comments-count-icon").attr("aria-hidden", "true").text("💬"))
+ .append($("").addClass("pr-comments-count-label").text(label))
+ .attr("aria-label", label + " — jump to the first comment")
+ .attr("data-total", String(total))
+ .prop("hidden", false);
+ // The first navigable target: the first thread of the first model bucket
+ // that maps to a current model (skip buckets whose model is not in the
+ // dropdown). Falls back to no-op navigation if none is anchorable.
+ $btn.off("click.cuteComments").on("click.cuteComments", function () {
+ navigateToFirstComment();
+ });
+ }
+
+ // Navigate to the first anchored review thread: select its model in the
+ // dropdown (selectModelByName → renderForSelectedModel re-renders the
+ // Model-SQL diff and re-injects the threads), then scroll to that thread's
+ // anchored diff line. In-page only (no fetch — the zero-egress contract).
+ function navigateToFirstComment() {
+ var pc = DATA && DATA.pr_comments;
+ if (!pc || !pc.by_model) { return; }
+ for (var i = 0; i < pc.by_model.length; i++) {
+ var bucket = pc.by_model[i];
+ var name = modelNameForBucket(bucket);
+ if (!name) { continue; }
+ selectModelByName(name);
+ var thread = bucket.threads && bucket.threads[0];
+ // Scroll to the injected thread once the Model-SQL diff has re-rendered.
+ scrollToThread(thread);
+ return;
+ }
+ }
+
+ // Scroll the injected comment thread (or its anchored diff line) into view.
+ // Defers a tick so the renderForSelectedModel re-render + injection settle.
+ function scrollToThread(thread) {
+ setTimeout(function () {
+ var target = thread && thread.__key
+ ? document.querySelector('.pr-comment-thread[data-thread-key="' + thread.__key + '"]')
+ : null;
+ if (!target) {
+ target = document.querySelector(".model-sql .pr-comment-thread");
+ }
+ if (target && target.scrollIntoView) {
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ }, 60);
+ }
+
+ // #421 — the per-model comment count, shown via the FOCUSABLE-BUBBLE
+ // tooltip (.expect-tooltip + .expect-tooltip-bubble): a focusable
+ // whose CSS bubble paints on :hover AND :focus — NEVER a native `title`
+ // (hover-delayed, keyboard-unreachable, touch-invisible). aria-label
+ // carries the count for AT; the bubble is aria-hidden (never announced
+ // twice). Hidden when the selected model has no anchored comments.
+ function renderModelCommentCount(m) {
+ var $row = $('[data-testid="model-comments"]');
+ if (!$row.length) { return; }
+ $row.empty();
+ var bucket = prCommentBucketFor(m);
+ if (!bucket || !bucket.count) { $row.prop("hidden", true); return; }
+ var n = bucket.count;
+ var word = n === 1 ? "comment" : "comments";
+ var aria = n + " review " + word + " on this model";
+ var $btn = $("").attr("type", "button")
+ .addClass("expect-tooltip model-comment-count-chip")
+ .attr("data-testid", "model-comment-count")
+ .attr("data-count", String(n))
+ .attr("aria-label", aria)
+ .text("💬 " + n + " " + word);
+ // The bubble: a per-thread summary (author of the first comment + a
+ // resolved/outdated marker), revealed on hover/focus.
+ var $bubble = $("").addClass("expect-tooltip-bubble model-comment-bubble")
+ .attr("data-testid", "model-comment-bubble").attr("aria-hidden", "true");
+ bucket.threads.forEach(function (t) {
+ var who = (t.comments && t.comments[0] && t.comments[0].author) || "ghost";
+ var where = t.outdated ? "outdated"
+ : (t.line != null ? "line " + t.line : "—");
+ var stateMark = t.resolved ? " · resolved" : "";
+ $bubble.append(
+ $("").addClass("model-comment-bubble-row")
+ .text("@" + who + " · " + where + stateMark)
+ );
+ });
+ $btn.append($bubble);
+ $row.append($btn).prop("hidden", false);
+ }
+
+ // #419 — inject each anchored review thread inline at its Model-SQL diff
+ // line. Runs AFTER renderModelSql has rebuilt the .model-sql diff for the
+ // selected model. A thread RESOLVED to a live line (Right → data-newline,
+ // Left → data-oldline) is inserted right after that diff line; an OUTDATED
+ // thread (no live line) — and any thread whose anchored line is not in the
+ // rendered diff — is appended to a per-model "outdated / unplaced comments"
+ // tail so reviewer context is never lost (the never-drop posture).
+ function anchorModelCommentThreads(m) {
+ var $section = $(".model-sql");
+ // Always clear a prior model's injected threads first (the
+ // renderModelSql rebuild contract — never leak across a model switch).
+ $section.find(".pr-comment-thread, .pr-comment-tail").remove();
+ var bucket = prCommentBucketFor(m);
+ if (!bucket || !bucket.threads.length) { return; }
+ var $diff = $section.find(".sql-diff-view");
+ var unplaced = [];
+ bucket.threads.forEach(function (t, idx) {
+ // Give each thread a stable per-model key so the navigation scroll can
+ // target it (and the headless test can assert it).
+ t.__key = (m.path || m.name || "model") + "#" + idx;
+ var $line = null;
+ if (!t.outdated && t.line != null && $diff.length) {
+ var attr = (t.side === "Left") ? "data-oldline" : "data-newline";
+ $line = $diff.find('.diff-line[' + attr + '="' + t.line + '"]').first();
+ }
+ if ($line && $line.length) {
+ $line.after(buildCommentThread(t));
+ } else {
+ unplaced.push(t);
+ }
+ });
+ if (unplaced.length) {
+ var $tail = $("").addClass("pr-comment-tail").attr("data-testid", "pr-comment-tail");
+ $tail.append(
+ $("
").addClass("pr-comment-tail-label")
+ .text(unplaced.length === 1
+ ? "1 comment refers to a line that changed or is not shown:"
+ : unplaced.length + " comments refer to lines that changed or are not shown:")
+ );
+ unplaced.forEach(function (t) { $tail.append(buildCommentThread(t)); });
+ // Place the tail after the diff (or the section wrap) so it is visible.
+ var $wrap = $section.find(".model-sql-wrap");
+ ($wrap.length ? $wrap : $section).append($tail);
+ }
+ }
+
+ // Build one review thread's DOM — GitHub-style: a header (anchor/line +
+ // resolved/outdated badges) over the comment rows (author + body). All
+ // strings are set via .text()/textContent (NEVER innerHTML) so a
+ // manifest/PR-derived body is drawn as text, never parsed as HTML.
+ function buildCommentThread(t) {
+ var $thread = $("
").addClass("pr-comment-thread")
+ .attr("data-testid", "pr-comment-thread")
+ .attr("data-thread-key", t.__key || "");
+ if (t.resolved) { $thread.addClass("pr-comment-resolved"); }
+ if (t.outdated) { $thread.addClass("pr-comment-outdated"); }
+ var $head = $("
").addClass("pr-comment-thread-head");
+ var anchorText = t.outdated
+ ? (t.original_line != null ? "outdated · was line " + t.original_line : "outdated")
+ : (t.line != null ? "line " + t.line : "comment");
+ $head.append($("
").addClass("pr-comment-anchor").text(anchorText));
+ if (t.resolved) {
+ $head.append($("").addClass("pr-comment-badge pr-comment-badge-resolved").text("resolved"));
+ }
+ if (t.outdated) {
+ $head.append($("").addClass("pr-comment-badge pr-comment-badge-outdated").text("outdated"));
+ }
+ $thread.append($head);
+ // Resolved threads collapse: the body lives in a , closed by
+ // default, so a resolved conversation is quiet but still inspectable.
+ var $body;
+ if (t.resolved) {
+ var $det = $("").addClass("pr-comment-collapse");
+ $det.append($("").text(
+ (t.comments ? t.comments.length : 0) === 1
+ ? "1 comment (resolved)"
+ : (t.comments ? t.comments.length : 0) + " comments (resolved)"
+ ));
+ $body = $("").addClass("pr-comment-body");
+ $det.append($body);
+ $thread.append($det);
+ } else {
+ $body = $("
").addClass("pr-comment-body");
+ $thread.append($body);
+ }
+ (t.comments || []).forEach(function (c) {
+ var $c = $("
").addClass("pr-comment");
+ $c.append($("
").addClass("pr-comment-author").text("@" + (c.author || "ghost")));
+ $c.append($("").addClass("pr-comment-text").text(c.body || ""));
+ $body.append($c);
+ });
+ return $thread;
+ }
+
function renderModelAttribution(m) {
var $row = $('[data-testid="model-attribution"]').empty();
var list = (m && m.config_attributions) || [];
@@ -6721,8 +7019,15 @@
Expected
closeNodeShelf();
renderModelAxes(m);
renderModelAttribution(m);
+ // cute-dbt#421 — the per-model PR review-comment count tooltip (focusable
+ // bubble). No-op when the selected model carries no anchored comments.
+ renderModelCommentCount(m);
renderTestDetails(t);
renderModelSql(m);
+ // cute-dbt#419 — inject the anchored review threads inline at their
+ // Model-SQL diff lines (and an outdated/unplaced tail). MUST run AFTER
+ // renderModelSql rebuilds the diff for this model. No-op without comments.
+ anchorModelCommentThreads(m);
// cute-dbt#247 — the Model YAML section (peer of Model SQL): the
// model's authored schema-file `models:` entry, or its truthful
// degrade placeholder.
@@ -9475,7 +9780,15 @@ Expected
: ln.kind === "removed" ? "diff-removed"
: "diff-context";
var sigil = ln.kind === "added" ? "+" : ln.kind === "removed" ? "-" : " ";
+ // cute-dbt#419 — carry the old/new line numbers as data attributes so
+ // the PR-comment injector (anchorModelCommentThreads) can find the exact
+ // rendered diff line a review thread anchors to (Right side → new line,
+ // Left side → old line). Decorative positional metadata only (the
+ // .diff-gutter precedent); absent the pr-comments surface they are inert.
+ var oAttr = (num && num.o != null) ? ' data-oldline="' + num.o + '"' : "";
+ var nAttr = (num && num.n != null) ? ' data-newline="' + num.n + '"' : "";
return '