-
Notifications
You must be signed in to change notification settings - Fork 1
[🔥AUDIT🔥] review: fix open-thread suppression, unreachable on every conforming run #302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "review": patch | ||
| --- | ||
|
|
||
| Open-thread suppression was unreachable on every conforming run, so re-reviews re-posted findings an open bot thread already tracked. `openThreadsFromStaged` accepted an opener author of `github-actions[bot]` only, but `get_review_comments` — the tool review.md tells the orchestrator to copy `threads.json` from, verbatim — renders that same account as bare `github-actions`, and the filter fails closed, so a *correct* staging produced zero usable threads. It also read `path` from the thread while the tool carries `path` per comment, and `suppressOpenThreadDuplicates` matches on `path`, so a thread that cleared the author check still suppressed nothing. Both spellings are now accepted (`BOT_AUTHORS`, documented against the REST `user.login` surface that legitimately renders the bracketed form and that `stage-pr.ts` reads for prior reviews), and `path` falls back to the opening comment. Measured on webapp#41197's three-round seeded lifecycle, where suppression reported `threadSuppressions: []` in all three rounds while the re-reviews duplicated open threads: 6 of round 3's 8 comments landed on the exact path and line of a thread that was already open, and the reconciler's own `keep` list held those thread IDs in the same run, so the data suppression needed was present and unusable. Every unit fixture spelled the bot the way the code did, which is why the suite passed throughout; the regression cases now use the tool's real shape verbatim, assert an end-to-end suppression (with the blocking thread still flooring the verdict) rather than only the parse, and keep a human-opened thread refused so the widened author check cannot drop a finding outright. The prompt's selection layer is widened to match, which is the same premise one layer up: review.md told the orchestrator to stage "the unresolved `github-actions[bot]` threads" and to classify a human thread as "any author other than `github-actions[bot]`", both bracketed-only, so a literal reading could stage zero bot threads before the widened code filter ever ran — or worse, misfile a bot thread into `human-threads.json`, where it lands in `skipLines` and makes the submission drop a fresh finding on that line rather than merely duplicate one. Both instructions now name either spelling and say why. Second half of the fix, since a fail-open guard that cannot be seen failing is how this survived a whole release: `stagedThreadShapeFailure` reports a staging whose shape defeats the filter entirely, as a `threadSuppressionUnavailable` field on `dispatch-result.json` and a run-log warning, so "nothing to suppress" is no longer indistinguishable from "suppression silently did nothing." It counts staged threads per `thread_id` against the reconciler's resolved set rather than by list length, so a long `resolve` list cannot mask a total shape failure in a short staging, and it documents its own limit: one usable thread returns nothing, making it a total-failure tripwire rather than a per-thread audit. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -230,22 +230,40 @@ const threadProse = (body: string): string => | |
| const stagedResolvedState = (thread: Record<string, unknown>): unknown => | ||
| thread["resolved"] ?? thread["is_resolved"] ?? thread["isResolved"]; | ||
|
|
||
| /** | ||
| * The bot's author spellings, both of which are the same identity. The REST | ||
| * surfaces (`user.login` on a review, which stage-pr.ts reads) render a bot | ||
| * as `github-actions[bot]`; `get_review_comments`, which stages the threads | ||
| * this module consumes, renders the SAME account as bare `github-actions`. | ||
| * Accepting only the bracketed form is what made suppression unreachable on | ||
| * every conforming run: the prompt tells the orchestrator to copy `author` | ||
| * from the tool output verbatim, so a correct staging never matched. | ||
| */ | ||
| const BOT_AUTHORS = new Set(["github-actions[bot]", "github-actions"]); | ||
|
|
||
| /** | ||
| * Build the suppression inputs from staged threads.json. The staging is | ||
| * prompt-executed (review.md asks the orchestrator for the unresolved | ||
| * github-actions[bot] threads; stage-pr.ts deliberately does not stage | ||
| * threads yet), so BOTH properties suppression depends on are enforced HERE | ||
| * in code rather than trusted from the prompt: | ||
| * - the opener is the bot's. A mis-staged human thread must never silently | ||
| * kill a candidate, and its free-text opener would also read as | ||
| * non-blocking and skip the verdict floor. | ||
| * - the opener is the bot's, in either spelling ({@link BOT_AUTHORS}). A | ||
| * mis-staged human thread must never silently kill a candidate, and its | ||
| * free-text opener would also read as non-blocking and skip the verdict | ||
| * floor. | ||
| * - the thread is still open, per the `resolved` flag staged from the tool's | ||
| * own `is_resolved`. A mis-staged already-resolved thread would otherwise | ||
| * suppress a genuine regression re-flag with nothing to check it against. | ||
| * Threads in resolvedIds (reconciler-resolved this run) are exempt as well: a | ||
| * fixed defect posting again is a fresh finding. Fails closed on each: a | ||
| * thread without a bot-authored opener, or without an explicit | ||
| * `resolved: false`, never suppresses (worst case is a duplicate comment). | ||
| * | ||
| * `path` is read from the thread and falls back to the opening comment, since | ||
| * `get_review_comments` carries `path` per comment rather than per thread and | ||
| * a verbatim staging inherits that shape. The fallback is not cosmetic: | ||
| * {@link suppressOpenThreadDuplicates} matches on `path`, so a thread staged | ||
| * without one silently suppresses nothing. | ||
| */ | ||
| export const openThreadsFromStaged = ( | ||
| threads: unknown, | ||
|
|
@@ -259,20 +277,26 @@ export const openThreadsFromStaged = ( | |
| Array.isArray(comments) && isRecord(comments[0]) | ||
| ? comments[0] | ||
| : undefined; | ||
| const author = opener?.["author"]; | ||
| if ( | ||
| typeof thread["thread_id"] !== "string" || | ||
| resolvedIds.has(thread["thread_id"]) || | ||
| stagedResolvedState(thread) !== false || | ||
| opener?.["author"] !== "github-actions[bot]" | ||
| typeof author !== "string" || | ||
| !BOT_AUTHORS.has(author) | ||
| ) { | ||
| return []; | ||
| } | ||
| const path = | ||
| typeof thread["path"] === "string" | ||
| ? thread["path"] | ||
| : typeof opener?.["path"] === "string" | ||
| ? (opener["path"] as string) | ||
| : undefined; | ||
| return [ | ||
| { | ||
| thread_id: thread["thread_id"], | ||
| ...(typeof thread["path"] === "string" | ||
| ? {path: thread["path"]} | ||
| : {}), | ||
| ...(path !== undefined ? {path} : {}), | ||
| body: | ||
| typeof opener["body"] === "string" | ||
| ? opener["body"] | ||
|
|
@@ -311,6 +335,54 @@ export const describesOpenThreadDefect = ( | |
| ); | ||
| }; | ||
|
|
||
| /** | ||
| * Whether staged threads ALL failed {@link openThreadsFromStaged}'s filter, so | ||
| * suppression could not run at all. Lives here beside the filter because it is | ||
| * the filter's own failure mode; the caller only logs what this returns. | ||
| * | ||
| * An empty {@link ThreadSuppression} list cannot distinguish "nothing to | ||
| * suppress" from "suppression silently did nothing", and that ambiguity is how | ||
| * the author-spelling mismatch above reached production: it survived a whole | ||
| * three-round seeded lifecycle (webapp#41197) posting duplicate comments while | ||
| * every run reported an empty suppression list and looked correct. | ||
| * | ||
| * Threads the reconciler resolved this run are excluded, since those are | ||
| * legitimately unusable — counted per thread against `resolvedIds` rather than | ||
| * by list length, because the reconciler's `resolve` list is never validated | ||
| * against the staged `thread_id`s: a long resolve list would otherwise mask a | ||
| * total shape failure in a short staging, silencing this very warning. | ||
| * | ||
| * Deliberate limit: ONE usable thread returns undefined, so partial shape | ||
| * drift (some threads malformed, others fine) stays invisible. This is a | ||
| * total-failure tripwire, not a per-thread audit; the per-thread version wants | ||
| * a rejection reason on each dropped thread, which is more machinery than the | ||
| * failure it would catch currently justifies. | ||
| */ | ||
| export const stagedThreadShapeFailure = ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (non-blocking): Low-confidence (1)
|
||
| threads: unknown, | ||
| openThreads: readonly OpenThread[], | ||
| resolvedIds: ReadonlySet<string>, | ||
| ): {unusableThreads: number; warning: string} | undefined => { | ||
| if (openThreads.length > 0) { | ||
| return undefined; | ||
| } | ||
| const unusableThreads = (Array.isArray(threads) ? threads : []) | ||
| .filter(isRecord) | ||
| .filter((thread) => { | ||
| const id = thread["thread_id"]; | ||
| return typeof id !== "string" || !resolvedIds.has(id); | ||
| }).length; | ||
| if (unusableThreads === 0) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| unusableThreads, | ||
| warning: | ||
| `::warning title=open-thread suppression::${unusableThreads} staged thread(s), none usable ` + | ||
| `(each needs thread_id, an explicit resolved: false, and a bot-authored opener); duplicates may re-post`, | ||
| }; | ||
| }; | ||
|
|
||
| /** | ||
| * Drop candidate claims that describe a defect an open bot thread already | ||
| * tracks (trial run S4 r2: the missing-test defect re-flagged at | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (non-blocking):
BOT_AUTHORSfixes the code filter, but the selection layer above it is unchanged: review.md still tells the orchestrator to stage "the unresolvedgithub-actions[bot]threads" and to classify a human thread as "any author other thangithub-actions[bot]" — both bracketed-only. Sinceget_review_commentsrenders the bot bare, an orchestrator following that literally could stage zero bot threads (or route them intohuman-threads.json) before this widened filter ever runs, and withstagedThreads == 0the new reporter returnsundefined, so that variant stays invisible. The prompt-side spelling is worth widening to match — arguably the same premise the PR diagnoses, one layer up.