* feat: stop repeating inline findings in the main comment * fix: keep inlined finding ids in the main comment, and cover dismissals and truncation * fix: trim inlined findings first when the aggregate comment is too large * fix: keep 3 inlined findings per scope, and restore threads before the /undismiss render * fix: keep the size floor at 3 per scope, and link a thread only once it shows the finding - The aggregate size cap kept 3 inlined plus 3 listed findings per scope, twice the old floor, so a comment that fit before could exceed GitHub's limit. Now every inlined finding is trimmed before a listed one, and a scope keeps at least 3 in total. The trim note says the trimmed ids can't be dismissed. - syncInline linked an existing thread before patching it. A stub whose restore failed or was skipped after a rate limit was linked, so the finding was shown nowhere. Now a thread is linked only when it already shows the finding or its update succeeded. - The inline row shows each finding's location: the fingerprint leaves out the line, so a moved finding keeps its thread on the old line. - The thread footer no longer says the main comment has the full review. * fix: give back room the size cap over-trimmed, and link only threads that show the finding - The inlined limit is shared by every scope, so one scope's overflow trimmed scopes that fit and dropped their input hash. Once the body fits, both limits are raised again by binary search, inlined first. - When 3 large findings still don't fit, the cap goes below the floor instead of returning a body GitHub rejects. - An outdated thread (GitHub returns line null once its line left the diff) is not linked, so its finding is listed in full. - /dismiss and /undismiss link a thread only when it shows the stored finding as it renders now, the rule syncInline uses, and pick the newest of duplicates. - LLP 0008 is split into shorter sections without the history, and LLP 0005, the plan, README and template describe the cap and the live-thread rule. --------- Co-authored-by: Brent Vatne <[email protected]>
12 KiB
Plan: inline PR comments for in-diff findings (rev 2, post adversarial review)
Goal
Findings anchored to a line in the PR diff land as inline review comments on
that line. The main issue comment does not list an inlined finding again: it
shows each inlined finding's id, linked to its thread, and its location, in one
row per severity. The main comment stays the single durable state store (embedded state, dismissals,
feedback records, pins, /dismiss target). Inline comments are a derived
presentation layer, converged on every run.
Non-goals (v1)
- No formal
POST /pulls/{n}/reviewsreview event: a batched review is atomic (one bad anchor 422s the whole post) and cannot be upserted across runs. Individual review comments isolate failures and PATCH in place. - No multi-line comments; single
line+side: "RIGHT"only. - No GraphQL thread resolution, and no detection of author-resolved threads.
Accepted risk: the PR author can resolve (collapse) an inline thread, and the
main comment then shows only its id; the thread stays on the PR and anyone can
expand it. Outdated threads (
line: null) are detected and listed in full. ecr feedbackretroactive crawl keeps reading issue comments only. The review-comments fetch inreplyCommentsis gated on the reporter having an ENABLEDinlineoption, so the crawl (which passes none) gains no fetches.- Scopes removed from the routing manifest strand their comments — same pre-existing behavior as their main comments.
- Break-glass (
postSkipNote) leaves inline threads untouched even though it overwrites the embedded state. Accepted, documented.
Config
Root-only key in ReviewConfigSchema, locked out of scope configs with
z.never like feedback:
inline: {
enabled: false, // ship dark
maxComments: 20 // cap per posted comment's reporter (per PR in single/
// legacy mode; per scope in per-scope mode — documented)
}
Wired into LoadedConfig, load.ts defaults, all five reporter construction
sites, and reviewPostingConfigFingerprint (posting behavior changes with it,
so a saved deferred review must not post under a different inline policy).
Turning it OFF later leaves existing threads up (documented; clear() still
sweeps them on mode switches, and a manual cleanup is possible).
Inline comment identity
Marker, at byte 0 of the body, parsed with an ANCHORED regex and a hex-only charset:
<!-- <tag>:inline:fp=<[a-f0-9]{6,64}> -->
- Identity = author (
user.login === ownLogin) + anchored marker; marker alone is never identity (selectOwnComments model). ownLogin unresolvable ⇒ nothing is ours AND no creates either (fail closed, no duplicate storms). - Byte-0 anchoring also narrows the shared
github-actions[bot]confused-deputy risk: an echo-bot that prefixes any content fails the anchor. - fp is the id the owning main comment renders (plain, or scope-namespaced for
the aggregate). Substring safety vs
<!-- tag -->/<!-- tag:scope -->holds in both directions (closing-->differs), incl. a scope namedinline.
Inline comment body
Marker line, then the finding rendered in full (severity, title, category,
id:<fp> token, rationale, sources, suggestion — every model-written field
through stripStateMarkers), then a footer: "Reply here to respond; write
id:<fp> in your reply (unquoted) to formally answer it."
Self-reply backstop (was blocker): the body carries an unquoted id: token,
and today's OWN_COMMENT_RE (:state=|:fingerprints=) would NOT exclude it if
identity/tag filters ever miss (bot-login fallback, commentTag rename, second
posting identity). Fixes, all three:
responses.tsgains a tag-independent backstop: a body whose UNQUOTED lines contain:inline:fp=is never treated as a reply (quoted lines excluded so a GitHub "Quote reply" of our inline comment — which copies the marker behind>— still counts as a genuine reply).replyCommentsapplies the same unquoted-line rule for the tag-specific marker.- Test: an inline body is never matched as a reply even with
ownLogin: nulland a foreign tag; a quote-reply containing the quoted marker IS matched.
Reporter mechanics
New option inline?: LoadedConfig["inline"].
Fetch layer
fetchAllReviewComments(): paginated GET repos/{repo}/pulls/{n}/comments
(fields: id, body, path, line, user.login, html_url, in_reply_to_id,
author_association). Cached in a MODULE-LEVEL map keyed
prNumber\nrepo\ncwd (the prAuthorByPr pattern) with the same 30s TTL and
invalidate-on-mutation — routed single-mode runs call clear() on a fresh
reporter per scope, and per-instance caching would multiply listings. Note:
this endpoint DOES honor sort/direction (unlike issue comments) — do not copy
that rationale comment; a full scan is still needed.
Sync
syncInline(targets, link, opts: { teardown: boolean }) runs inside
report() / reportAggregate() before rendering; returns Map<fp, url>.
Targets = ACTIVE findings only (not dismissed, not reply-cleared, not
requalified) with line != null and diffLines.get(file).has(line).
Cap (sticky, was churn bug): order candidates by severity rank then fp (deterministic); fill slots preferring fps that ALREADY have a live inline comment, then admit new ones. A live thread is never evicted to create a new one.
Teardown gating (was blocker): stubs/deletes run ONLY when
opts.teardown is true, which callers set as:
report():!review.couldNotComplete.reportAggregate(): caller-passed flag — ci.ts passes!scopesFilter && results.every(r => !r.review.couldNotComplete). A partial--scopesrun or any failed scope makes the run additive-only (creates + patches, no stubs/deletes), because carried/truncated/failed state cannot prove a finding is gone. (scopedFingerprintis opaque hex — a per-scope stale set cannot be derived from fps, so the gate is all-or-nothing.)applyDismissal: never computes a stale set. Additive-only: stub threads of the explicitly dismissed fps; on/undismiss, re-patch the full body if the finding is present in state (skip if truncated away).- Prereq fix: both failure literals (
ci.tslegacy catch andfailureReview()) gaincouldNotComplete: true— today they post empty-findings reviews WITHOUT the flag and would tear everything down. Side effects checked: the decision label becomes the accurate "No review — every pass failed", and caching is unaffected (failures already never carry inputHash).
Stub text (was blocker): neutral — "This finding is no longer tracked inline — see the main review comment for current status." Never "resolved/dismissed": a capped-out or drifted finding is still active.
Mutations (sequential, never parallel):
- create: POST with body/commit_id=headSha/path/line/side. Creates require
headSha AND a live-head check: one
gh pr view --json headRefOidper sync (only when creates exist); mismatch ⇒ skip creates, log (a mid-review push means our diff no longer matches the head; a stale commit_id can silently anchor to wrong code, and a force-push 422s every create). - patch: only when the normalized body differs (
\r\n→\n) — no PATCH-per- finding-per-run forever. - stale (teardown only): thread has replies ⇒ PATCH to the neutral stub
(marker kept, so a returning finding revives the thread); no replies ⇒
DELETE. Reply detection: any listed comment with
in_reply_to_id== our id (same snapshot; the reply-between-list-and-delete race is accepted, noted in tests). - duplicates (crash window): keep newest per fp, older are stale.
- 422 on create ⇒ log once + skip that finding (renders full-form). 403/429 (secondary rate limit) ⇒ log + abort ALL remaining mutations this run.
- Audit (was silent): stderr one-liner + step-summary line:
inline sync: N created, N patched, N stubbed, N deleted, N failed.
Every mutation individually try/caught; the inline layer never blocks the main comment. Listing failure ⇒ empty map, no mutations.
clear()
Extends to inline comments under this reporter's tag, gated on marker+author
only (NOT on enabled, so mode switches clean up after a disable): replied
threads are STUBBED, bare ones deleted — never delete a thread humans wrote in
(ci.ts calls clear() on mode switches and scope reconciliation). This also
covers the fp-namespace flip on per-scope↔single switches, symmetric with how
main comments are cleared today.
Main-comment id rows
LinkContext.inlineUrls?: Map<string, string>. NEVER mutate the shared routed-flowlinkobject — render with a copy{ ...link, inlineUrls }.renderSeveritySectionsleaves every finding with an inline URL out of the severity lists. Under💬 **Inline comments on the diff (n)**it adds one row per severity with each finding'sid:linked to its thread, plus a reply link when an author answered. Severity headings count only the findings listed under them. Missing URL ⇒ full form (tested). See LLP 0008 "Inline Threads".- Embedded state unchanged.
Inline replies → feedback
replyComments()maps review comments too — ONLY whenoptions.inline?.enabled(keepsecr feedbackcrawl and non-inline repos at zero extra fetches). Excludes own login; excludes bodies whose unquoted lines carry our markers.- Id namespace (was collision risk): review-comment-sourced ReplyComments
get
id = -reviewCommentId. Negative ids flow into FeedbackRecord.commentId (schema unchanged — number stays a number) and are consistent across runs, so mergeFeedback'scommentIdequality and buildAdjudicationItems' body lookup stay collision-free vs the positive issue-comment space. applyPinsgains a cross-stream guard: a pin is lifted only by a maintainer record whose commentId is newer AND in the SAME stream (same sign). A different-stream comparison is undecidable ordering ⇒ keep the pin (fail closed).ReplyComment.threadFp?: string: set when the reply'sin_reply_to_idresolves to OUR (author+anchored-marker) top-level inline comment's fp.matchRepliestreats a known threadFp as a match in EVERYmatchmode (structural signal, not text matching; documented + tested).citedIdis unchanged: clearing still requires the replier's own unquotedid:token.- Newest-wins across streams: negative ids lose to positive ones by raw comparison; acceptable because only the newest record per fp matters and a same-fp cross-stream tie is rare; the applyPins guard removes the dangerous consequence.
Files touched
src/config/schema.ts,src/config/load.ts—inlinekey + lock + defaults.src/core/deferred-review.ts— fingerprint includesinline.src/reporters/github.ts— review-comments fetch (shared cache), syncInline- pure exported planner, replyComments extension, clear(), applyDismissal additive sync.
src/core/render.ts— inline marker helpers (anchored parse), inline body renderer,LinkContext.inlineUrls, main-comment id rows.src/core/responses.ts— threadFp, unquoted-marker backstop.src/core/schema.ts— applyPins same-stream guard.src/commands/ci.ts— wire config,couldNotComplete: trueon both failure literals, aggregate teardown flag.review.ts,post-review.ts,dismiss.ts— wire config.- Tests: planner (create/patch/stub/delete, sticky cap, dedupe, additive mode), marker anchor/forgery, id rows + missing-URL fallback, backstop vs quote-reply, threadFp in all match modes, applyPins stream guard, couldNotComplete literals.
templates/config.jsonc,README.md.
Rollout
Ship dark. Note in README: first enable on a large PR fires one notification per created inline comment (cap bounds it). Enable on this repo first, then expo/expo after one observed cycle.