Skip to content

feat(#106): reconcile findings that have already been dismissed - #1066

Open
guyoron1 wants to merge 3 commits into
fullsend-ai:mainfrom
guyoron1:feat/106-reconcile-declined-findings
Open

feat(#106): reconcile findings that have already been dismissed#1066
guyoron1 wants to merge 3 commits into
fullsend-ai:mainfrom
guyoron1:feat/106-reconcile-declined-findings

Conversation

@guyoron1

@guyoron1 guyoron1 commented Aug 27, 2026

Copy link
Copy Markdown

Heyyaa all : )

Quick reminder — this is the take-over of #709 that Ralph asked for on the review-economy thread ("please take over #709 or start it from scratch"), closing agents#106 (review agent re-raising findings that were already legitimately dismissed).

What this does

On re-review, dismissed findings (reply, resolved thread, or 👎 — all from someone with real authority, never the PR author) get downgraded to low instead of re-raised, as long as the dismissed code hasn't changed (low, not info — info is stripped by the default severity threshold, which would silently delete both the finding and its round-to-round markers). Critical findings never downgrade by dismissal, high findings dismiss only by written reply, and a disputed finding is only argued once.

Ralph's design (#709) is the base — trust gate, decline-vs-round-boundary matching, downgrade-not-drop. Added: resolve/👎 as dismissals via one GraphQL query, the critical/high carve-out, engage-once for disputes, and a fail-closed fix for a trust-gate case that 403s inside the read-only review sandbox (details in-thread).

Already went a round with qodo — 5 findings fixed, 1 refuted (schema check in-thread), 2 are fail-closed-by-design truncation limits.

Not included

An eval case (blocked by #245 — self-review token issue) and GitLab parity (signals exist, but no live instance to verify the trust mapping against).

Heads up for review

skills/ is a protected path, so a protected-path finding here is expected. functional-tests-complete needs a maintainer's ok-to-test after the last push.

…issed

Picks up ralphbean's fullsend-ai#709 at his invitation on the review-economy thread
("please take over ... or start it from scratch and we can discard
mine"). The design is his; this keeps it, closes three of the gaps its
own non-goals list, and moves the forge calls where they now belong.

Kept from fullsend-ai#709, unchanged in substance: the two-tier trust gate
(author_association in OWNER/MEMBER/COLLABORATOR, falling back to the
collaborator permission API for admin/maintain/write, the same pair
check-e2e-authorization.sh uses); the PR author never dismissing their own
findings even holding a qualifying role; keying the dismissal to whether
the dismissed code is still present rather than to a round boundary;
matching on file + category, never line; downgrading to info +
actionable:false rather than dropping; and the experimental framing with
explicit non-goals.

Split across the forge boundary. fullsend-ai#709 puts a `gh api` call in the shared
SKILL.md. That skill is now forge-abstracted — it delegates every fetch
to "the forge-specific review skill's <section>" and pr-review/github and
pr-review/gitlab supply the commands — so the dismissal fetch follows
suit: the shared skill carries the semantics, github/SKILL.md carries the
GraphQL query, and step 2a-1 skips when a forge has no such section. That
keeps GitLab on today's behavior instead of breaking it.

What this adds:

1. Non-reply dismissals. fullsend-ai#709's non-goals name these; they are the two
   things people actually reach for when a finding is not worth a
   sentence. Resolving the conversation and a thumbs-down on the bot's
   comment now count, and both are trust-gated exactly like a reply. One
   GraphQL reviewThreads query replaces the paginated REST call and
   carries all three signals: resolution is GraphQL-only, reactions cost
   a request per comment over REST, and threads arrive pre-grouped, so
   the in_reply_to_id chain walk goes away with it.

2. A critical carve-out. As fullsend-ai#709 stands, any severity downgrades to info,
   critical included, and stays there for as long as the code is
   unchanged — which is the case where it should stay unchanged. Critical
   findings are now emitted at critical with the dismissal noted
   alongside. info + actionable:false resolves to `approve` in 6f, and
   that is the one outcome a critical finding must not produce.
   Refutation still downgrades a critical finding, because that is a
   verified judgment about the code rather than a dismissal of it.

3. Disputes, engaged exactly once. "This isn't a bug, because X" is not a
   decline, so under fullsend-ai#709 it falls through and the finding re-raises
   verbatim next push — fullsend-ai#106 wearing a different hat. The argument is
   judged on its merits and is deliberately not trust-gated (the PR
   author is usually the one making it, and correctness is not a
   permission). Refuted, it downgrades; not refuted, the finding stands
   with one sentence engaging it, and the exchange is over.

4. A fail-closed trust boundary, because the collaborator-permission
   fallback does not work from where this runs. GitHub rejects that
   endpoint without push access ("Must have push access to view
   collaborator permission"), and the review agent is deliberately
   read-only — readonly_repo: true, providers/github-ro.yaml, and a
   policy whose own comment says "No write access to GitHub". So the
   second tier generally 403s in the sandbox. Any error is treated as
   not trusted: the dismissal does not count and the finding is emitted
   normally.

   That has a consequence worth stating rather than leaving to be
   discovered: on a private organization, where a real admin's
   association reports as CONTRIBUTOR, tier one under-reports and tier
   two cannot compensate. Rather than leave the new signals dead on
   arrival — resolvers and reactors carry no association at all — a
   middle tier looks the login up among the associations the same query
   already returned for this PR's thread comments, which covers the
   common case at no extra request. Closing the gap properly means
   resolving trust on the runner, where a write-scoped token exists, and
   passing the result in; that is a separate change.

Two corrections to fullsend-ai#709's text, both verified rather than assumed:

- fullsend#6045 has shipped, so the review app's identity no longer has
  to be a literal. FULLSEND_SLUG is exported into the sandbox from the
  harness identity and is in reservedSandboxKeys so env.sandbox cannot
  shadow it. The configured login stays only as the fallback for a
  harness that declares no slug.
- The bot's login has two spellings and the query returns both at once.
  GraphQL reports a Bot-typed author without the [bot] suffix — the form
  FULLSEND_SLUG holds, so it compares directly — while REST's user.login
  and a bot appearing under resolvedBy (typed User, not Bot) both carry
  it. fullsend#6456 corrected this same mismatch in another skill.

Verified against live data rather than from the schema: every field in
the query — isResolved, resolvedBy, authorAssociation, diffHunk,
reactionGroups.reactors, the pageInfo flags — was run against real review
threads on fullsend-ai#1003, and the snippet was executed exactly
as it appears in github/SKILL.md (exit 0). Three behaviours worth knowing
came out of that and are documented next to the query: reviewThreads
returns oldest-first, so it uses last: 100 while comments within a thread
stay first: 50 so nodes[0] is the root; `line` comes back null with
originalLine set once a comment's diff position goes stale, which on a
re-review is the common case; and reactionGroups returns all eight
contents even at zero, so totalCount must be checked before reading
reactors.

Not included, and neither omission is a shortcut:

An eval case is blocked twice over. eval/review/cases/*/input.yaml
expresses only forge, seed_issues and fixture — there is no way to seed a
prior review, review threads, replies, resolution or reactions, and the
case lifecycle is a single agent run. Even given that, the runner reuses
GH_TOKEN as REVIEW_TOKEN, so a seeded review comment would carry no
performed_via_github_app.client_id, PRIOR_REVIEW_PROVENANCE would be
unverifiable-no-app, and step 2a-1 would skip by design. That is agents#245.
Worth revisiting when fullsend-ai#245 lands, because this is behaviour that will rot
silently.

GitLab parity is left explicitly unimplemented rather than guessed. The
signals exist there — discussions carry resolved and resolved_by,
award_emoji carries the reaction — but the trust boundary has no verified
field mapping and I have no live instance to check one against. That is
the part that must not be approximated, so gitlab/SKILL.md says so and
step 2a-1 skips when a forge provides no section.

Signed-off-by: guy oron <goron@redhat.com>
@guyoron1
guyoron1 requested a review from a team as a code owner August 27, 2026 11:16
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Functional tests are running

Authorization passed for this commit. See the Functional Tests workflow for results.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Reconcile dismissed findings during PR re-reviews

✨ Enhancement 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Reconciles trusted maintainer dismissals without repeatedly blocking unchanged findings.
• Preserves critical findings and evaluates technical disputes once per finding.
• Fetches GitHub dismissal signals via GraphQL while retaining GitLab's existing behavior.
Diagram

graph TD
  A["Prior findings"] --> B{"Dismissal support"}
  B -->|GitHub| C["Review threads"] --> D{"Trusted actor"} -->|Yes| E{"Code unchanged"} -->|Yes| F["Reconcile finding"] --> G["Review verdict"]
  B -->|GitLab| G
  D -->|No| G
  E -->|No| G
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Runner-side trust resolution
  • ➕ Uses the runner's write-scoped token to identify private-organization maintainers reliably
  • ➕ Removes expected collaborator-permission failures from the review sandbox
  • ➖ Requires harness and data-contract changes beyond the review skill
  • ➖ Expands implementation scope and cross-component review risk
2. REST endpoint aggregation
  • ➕ Uses familiar GitHub pull-request comment APIs
  • ➕ Could reuse existing REST pagination patterns
  • ➖ Cannot obtain thread resolution from review-comment REST responses
  • ➖ Requires per-comment reaction requests and reply-chain reconstruction

Recommendation: Keep the PR's GraphQL-based, forge-specific implementation as the best incremental approach: it gathers all GitHub signals in one grouped query and fails closed when authority cannot be established. Runner-side trust resolution is the strongest follow-up for private organizations, but it should remain separate because it changes harness boundaries and credential handling.

Files changed (3) +285 / -0

Enhancement (2) +269 / -0
SKILL.mdDefine trusted dismissal reconciliation for re-reviews +185/-0

Define trusted dismissal reconciliation for re-reviews

• Adds an experimental re-review workflow that recognizes trusted non-author replies, thread resolutions, and thumbs-down reactions as dismissal signals. It reconciles only unchanged code, keeps critical findings blocking, constrains untrusted reply input, and limits repeated engagement with disputed findings.

skills/pr-review/SKILL.md

SKILL.mdFetch GitHub review-thread dismissal signals +84/-0

Fetch GitHub review-thread dismissal signals

• Adds a read-only GraphQL query for review threads, replies, resolution actors, reactions, stale anchors, and author associations. Documents bot-login normalization, pagination limits, and a fail-closed collaborator-permission fallback.

skills/pr-review/github/SKILL.md

Documentation (1) +16 / -0
SKILL.mdDocument safe GitLab dismissal fallback +16/-0

Document safe GitLab dismissal fallback

• Declares GitLab dismissal reconciliation unsupported so the shared workflow preserves existing behavior. It outlines relevant discussion and emoji APIs while deferring implementation until project-role trust mapping is verified.

skills/pr-review/gitlab/SKILL.md

@qodo-code-review

qodo-code-review Bot commented Aug 27, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (2)

Grey Divider


Action required

1. GitLab capability guard misfires ✓ Resolved 📜 Skill insight ≡ Correctness
Description
The shared skill skips dismissal fetching only when the forge lacks a Review thread dismissals
section, but GitLab now declares that exact section while stating the feature is unimplemented and
providing no commands. GitLab re-reviews therefore enter a mechanism with no producer and are
directed to fetch dismissal data through nonexistent commands instead of preserving the promised
skip behavior.
Code

skills/pr-review/gitlab/SKILL.md[R81-85]

+## Review thread dismissals
+
+Not implemented for GitLab. Step 2a-1 checks for this section and skips
+when a forge does not provide it, so re-reviews on GitLab keep today's
+behavior rather than failing.
Relevance

●●● Strong

The shared guard treats section presence as capability, while GitLab explicitly provides no
implementation; this contradicts the documented skip behavior.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The shared guard uses absence of the exact Review thread dismissals section as its skip condition;
when the section exists, it directs the agent to execute the commands provided there. The new GitLab
section has that exact heading but says Not implemented and contains no commands, demonstrating a
mismatch between the shared consumer's capability expectations and GitLab's actual producer support.

skills/pr-review/SKILL.md[222-236]
skills/pr-review/gitlab/SKILL.md[81-95]
Skill: code-review
Skill: pr-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The placeholder GitLab `Review thread dismissals` heading activates a shared dismissal-fetching step that expects executable commands, even though the capability is not implemented.

## Issue Context
The shared skill infers support from the presence of the section: it skips only when the section is absent and otherwise expects the section to contain executable dismissal-fetch commands. Remove or rename the GitLab section until the feature is implemented, or introduce an explicit supported/unsupported marker that the shared guard checks before fetching.

## Fix Focus Areas
- skills/pr-review/SKILL.md[222-236]
- skills/pr-review/gitlab/SKILL.md[81-95]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. reactors invalidates GraphQL query 📜 Skill insight ≡ Correctness
Description
GitHub's ReactionGroup exposes reacting accounts through users, not reactors; this invalid
selection causes the combined GraphQL request to fail before any reply, resolution, or reaction data
is returned. The dismissal mechanism therefore cannot run on GitHub as written.
Code

skills/pr-review/github/SKILL.md[87]

+     reactionGroups{ content reactors(first:10){ totalCount nodes{ ... on User { login } } } }
Relevance

●●● Strong

An invalid GraphQL field is a deterministic correctness failure that prevents the entire dismissal
query from executing.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed query uses reactors(first:10) while the shared workflow depends on this single query
returning all three dismissal signals. An invalid GraphQL field prevents the producer from supplying
the data expected by step 2a-1.

skills/pr-review/github/SKILL.md[71-92]
skills/pr-review/SKILL.md[232-236]
Skill: code-review
Skill: pr-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The GraphQL query selects the nonexistent `ReactionGroup.reactors` field, causing GitHub to reject the complete dismissal query.

## Issue Context
GitHub exposes the accounts associated with a reaction group through its `users` connection. Update both the query and the response-reading instructions consistently.

## Fix Focus Areas
- skills/pr-review/github/SKILL.md[87-87]
- skills/pr-review/github/SKILL.md[109-111]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Reply truncation ignores reversals ✓ Resolved 📜 Skill insight ≡ Correctness
Description
The query fetches only the first 50 thread comments without pagination, even though reconciliation
requires the most recent qualifying reply to determine dismissal. A later reversal or dismissal can
therefore remain invisible, allowing an earlier obsolete reply to produce the wrong reconciliation
outcome or downgrade a finding.
Code

skills/pr-review/github/SKILL.md[R82-88]

+    comments(first:50){ nodes{
+     author{ __typename login }
+     authorAssociation
+     body createdAt path diffHunk
+     line originalLine startLine originalStartLine
+     reactionGroups{ content reactors(first:10){ totalCount nodes{ ... on User { login } } } }
+    }}
Relevance

●●● Strong

The most-recent-reply requirement makes unpaginated comments a direct reliability defect; recent
history accepts analogous pagination fixes.

PR-#1038

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The shared algorithm explicitly requires the most recent qualifying reply to win, while the GitHub
query caps the ordered comment connection at comments(first:50) without exposing comment
pagination state; pagination is documented only for the outer reviewThreads connection. Therefore,
replies beyond the first comment page cannot participate in reconciliation.

skills/pr-review/SKILL.md[289-300]
skills/pr-review/github/SKILL.md[77-105]
skills/pr-review/github/SKILL.md[82-100]
Skill: code-review
Skill: pr-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The fixed `comments(first:50)` query truncates thread replies and cannot guarantee that the most recent qualifying reply determines dismissal state.

## Issue Context
Fetch all comments for each relevant thread before applying the latest-reply rule, while preserving chronological order and keeping the root comment identifiable. Add comment page information and pagination instructions so later replies, including reversals or dismissals beyond the first page, are not treated as absent.

## Fix Focus Areas
- skills/pr-review/github/SKILL.md[82-100]
- skills/pr-review/github/SKILL.md[99-105]
- skills/pr-review/SKILL.md[289-300]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (3)
4. Dismissal matches unrelated findings ✓ Resolved 🐞 Bug ≡ Correctness
Description
Matching only on file and category lets one dismissed thread downgrade every same-category finding
in that file when the dismissed snippet still exists anywhere in the file. This can make an
unrelated, never-dismissed defect non-actionable and change the verdict to approval.
Code

skills/pr-review/SKILL.md[R1251-1253]

+For each finding in the merged set, look for a `DISMISSED_FINDINGS` entry
+matching on **file and category** — not line number, since a finding's
+line can drift across rounds even when the underlying code is untouched.
Relevance

●● Moderate

The PR explicitly adopts file/category matching and content validation; changing that identity model
is semantic and subjective.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The initial correlation uses line and category/description, but the stored record omits that
identity; emission then deliberately matches only file/category and searches the dismissed code
anywhere in the file before downgrading the candidate.

skills/pr-review/SKILL.md[310-320]
skills/pr-review/SKILL.md[1251-1267]
schemas/review-result.schema.json[106-121]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A dismissal is later matched using only file and category, so it can be applied to a different finding in the same file.

## Issue Context
The thread is initially correlated using location plus category/description, but that identity is discarded before emission. Preserve enough normalized finding identity to associate the dismissal only with the originally matched finding while still tolerating line drift.

## Fix Focus Areas
- skills/pr-review/SKILL.md[310-320]
- skills/pr-review/SKILL.md[1251-1267]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Repeated dispute silences defects ✓ Resolved 🐞 Bug ≡ Correctness
Description
After one non-refuting exchange, the rule ignores every later reply and automatically downgrades any
below-critical finding to non-actionable info even though the finding was never refuted. A
still-valid high-severity defect can therefore stop blocking the PR merely because it was disputed
across reviews, while a later substantive refutation is never evaluated.
Code

skills/pr-review/SKILL.md[R1304-1309]

+Never argue the same finding across two re-reviews. If the matched prior
+finding's text already contains "Author's justification considered:", that
+one exchange has already happened: emit the finding unchanged and add
+nothing further, whatever the new reply says. Below critical, also
+downgrade it to `info` with `actionable: false` so a standing
+disagreement stops blocking the PR. At critical it keeps its severity —
Relevance

●● Moderate

The one-dispute rule is explicitly stated PR intent; correctness concerns are substantial but no
close rejection precedent supports certainty.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The first failed refutation explicitly retains assessed severity, but the next-round branch ignores
new replies and changes all non-critical findings to info/non-actionable; the verdict logic permits
approval when only low/info findings remain.

skills/pr-review/SKILL.md[1300-1311]
skills/pr-review/SKILL.md[1385-1388]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The once-only dispute rule automatically de-escalates an unrefuted finding and ignores later technical evidence.

## Issue Context
Avoid repeating the same prose exchange, but do not treat repeated disagreement as refutation. Keep an unresolved finding at its assessed severity and evaluate genuinely new evidence without appending duplicate engagement text.

## Fix Focus Areas
- skills/pr-review/SKILL.md[1290-1314]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Fallback bot identity undefined ✓ Resolved 📜 Skill insight ≡ Correctness
Description
When FULLSEND_SLUG is absent, the new instructions fall back to a configured review-app login,
but no concrete variable or configuration source for that value is defined. In the explicitly
supported no-slug case, the agent cannot reliably identify its own threads and populate dismissal
state.
Code

skills/pr-review/SKILL.md[R241-246]

+the forge's other APIs use for the same account. Prefer `${FULLSEND_SLUG}`
+over a literal login: the runner exports it into the sandbox from the
+harness identity and `env.sandbox` cannot shadow it
+([fullsend#6045](https://github.com/fullsend-ai/fullsend/issues/6045)). It
+is emitted only when the harness declares a `slug`, so fall back to the
+configured review-app login when it is unset.
Relevance

●● Moderate

Fallback identity ambiguity is plausible, but no close accepted or rejected precedent establishes
this exact configuration requirement.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed instructions make thread ownership depend on an unnamed fallback. Repository
configuration exposes FULLSEND_SLUG terminology but contains no configured review-app-login
identifier, leaving the fallback path without a producer.

skills/pr-review/SKILL.md[238-246]
skills/pr-review/github/SKILL.md[113-119]
harness/review.yaml[61-67]
Skill: code-review
Skill: pr-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The fallback review-agent login has no named runtime source when `FULLSEND_SLUG` is unset.

## Issue Context
Introduce a concrete harness/environment value for the configured review-app login and document the exact producer-to-consumer contract, or make a missing identity fail closed by skipping dismissal reconciliation explicitly.

## Fix Focus Areas
- skills/pr-review/SKILL.md[238-246]
- skills/pr-review/github/SKILL.md[113-119]
- harness/review.yaml[61-67]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Older dismissals are truncated 🐞 Bug ☼ Reliability
Description
The query fetches only the newest 100 review threads and returns no cursor with which to perform the
documented continuation. Dismissals attached to older prior findings are therefore unavailable and
get re-raised, which is the behavior this feature is intended to prevent.
Code

skills/pr-review/github/SKILL.md[R77-79]

+  reviewThreads(last:100){
+   pageInfo{ hasPreviousPage }
+   nodes{
Relevance

●●● Strong

Recent review history explicitly accepted pagination fixes for bounded prior-state fetches,
including PR #1038.

PR-#1038

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The query requests last:100 and only hasPreviousPage, while the prose acknowledges omitted older
threads but offers no cursor or follow-up query. The repository has previously accepted the same
bounded-fetch bug pattern for prior-review data.

skills/pr-review/github/SKILL.md[70-93]
skills/pr-review/github/SKILL.md[101-105]
PR-#1038

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The dismissal query cannot retrieve review threads older than the final 100.

## Issue Context
Request the appropriate page cursor and provide executable pagination/aggregation commands so all review threads needed to correlate prior findings are available.

## Fix Focus Areas
- skills/pr-review/github/SKILL.md[70-93]
- skills/pr-review/github/SKILL.md[101-105]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Trusted reactors are truncated 🐞 Bug ☼ Reliability
Description
The query returns only the first ten reactors for each reaction group and provides no cursor or
follow-up fetch. A trusted maintainer's 👎 outside that slice is invisible, so a valid dismissal is
not honored even though totalCount indicates additional reactors exist.
Code

skills/pr-review/github/SKILL.md[87]

+     reactionGroups{ content reactors(first:10){ totalCount nodes{ ... on User { login } } } }
Relevance

●●● Strong

A capped reactor list can silently miss authorized dismissals; this is a direct reliability gap in
the stated trust algorithm.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The query caps reactor identities at ten; total count alone cannot identify or trust-check omitted
users, yet the shared logic requires the reactor's login and authority before honoring a
thumbs-down.

skills/pr-review/github/SKILL.md[82-111]
skills/pr-review/SKILL.md[248-263]
skills/pr-review/SKILL.md[289-296]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Only ten reactor identities are available for trust evaluation.

## Issue Context
When the thumbs-down group's total exceeds the returned node count, paginate its reactors and aggregate all logins before applying the trust gate.

## Fix Focus Areas
- skills/pr-review/github/SKILL.md[82-111]
- skills/pr-review/SKILL.md[248-263]
- skills/pr-review/SKILL.md[289-296]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

9. Protected skills require human review 📜 Skill insight § Compliance
Description
This PR modifies multiple files under the protected skills/ path. Although issue #106 and the PR
description justify the changes, the checklist requires a protected-path finding so these governance
instructions cannot be auto-approved.
Code

skills/pr-review/SKILL.md[R211-214]

+### 2a-1. Dismissals of prior findings (re-reviews)
+
+**Status: experimental.** This step narrows one specific case of
+[agents#106](https://github.com/fullsend-ai/agents/issues/106) — someone
Relevance

● Weak

Recent precedent rejected adding protected-skills human-review guidance; governance finding is
intentionally not converted.

PR-#753

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The diff changes the shared review skill and both forge-specific review skills, all within the
checklist's explicitly protected skills/ path. The linked issue supplies justification but does
not remove the mandatory human-approval requirement.

skills/pr-review/SKILL.md[211-214]
skills/pr-review/github/SKILL.md[60-68]
skills/pr-review/gitlab/SKILL.md[81-85]
Skill: pr-review


Grey Divider

Context sources
✅ Compliance rules (platform): 56 rules
✅ Skills: 4 invoked
  code-review
  code-implementation
  pr-review
  docs-review
Review mode: ⚖️ Balanced: Downgraded extended -> standard: change is below the extended eligibility bar (hunks 4/18, lines 285/200; both must reach the floor). Router rationale: This is a security-sensitive, behavior-heavy reconciliation feature spanning shared orchestration and GitHub/forge integration, with multiple independent trust, GraphQL, matching, and severity paths that warrant redundant review.

Grey Divider

Tip of the day
💡 Did you know, you can reply 'qodo' on any finding to push back, ask questions, or dig deeper

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

authorAssociation
body createdAt path diffHunk
line originalLine startLine originalStartLine
reactionGroups{ content reactors(first:10){ totalCount nodes{ ... on User { login } } } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. reactors invalidates graphql query 📜 Skill insight ≡ Correctness

GitHub's ReactionGroup exposes reacting accounts through users, not reactors; this invalid
selection causes the combined GraphQL request to fail before any reply, resolution, or reaction data
is returned. The dismissal mechanism therefore cannot run on GitHub as written.
Agent Prompt
## Issue description
The GraphQL query selects the nonexistent `ReactionGroup.reactors` field, causing GitHub to reject the complete dismissal query.

## Issue Context
GitHub exposes the accounts associated with a reaction group through its `users` connection. Update both the query and the response-reading instructions consistently.

## Fix Focus Areas
- skills/pr-review/github/SKILL.md[87-87]
- skills/pr-review/github/SKILL.md[109-111]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reactors is valid — the claim is backwards. users is the older ReactionGroup field, deprecated in favor of reactors; its own deprecation notice says to use reactors. Default introspection hides deprecated fields, which may be where the confusion came from:

$ gh api graphql -f query='{ __type(name:"ReactionGroup"){ fields(includeDeprecated:true){ name isDeprecated deprecationReason } } }' \
    --jq '.data.__type.fields[] | .name + (if .isDeprecated then "  [deprecated: "+.deprecationReason+"]" else "" end)'
content
createdAt
reactors
subject
users  [deprecated: Reactors can now be mannequins, bots, and organizations. Use the `reactors` field instead. Removal on 2021-10-01 UTC.]
viewerHasReacted

And the query doesn't fail — here it is, extracted verbatim from this file at the PR head and run against this PR's own review threads (the ones this bot posted):

$ REPO_FULL_NAME=fullsend-ai/agents PR_NUMBER=1066   # then the snippet exactly as committed
$ echo "$DISMISSALS" | jq -e 'has("errors") | not' && \
  echo "$DISMISSALS" | jq '.data.repository.pullRequest.reviewThreads.nodes | length'
true
8

exit 0, no errors key, and every thread carries the full reactionGroups array with reactors.totalCount. No change needed.

Comment thread skills/pr-review/gitlab/SKILL.md Outdated
Comment thread skills/pr-review/github/SKILL.md Outdated
Comment thread skills/pr-review/SKILL.md Outdated
Comment thread skills/pr-review/SKILL.md
Comment thread skills/pr-review/SKILL.md Outdated
Comment on lines +77 to +79
reviewThreads(last:100){
pageInfo{ hasPreviousPage }
nodes{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

8. Older dismissals are truncated 🐞 Bug ☼ Reliability

The query fetches only the newest 100 review threads and returns no cursor with which to perform the
documented continuation. Dismissals attached to older prior findings are therefore unavailable and
get re-raised, which is the behavior this feature is intended to prevent.
Agent Prompt
## Issue description
The dismissal query cannot retrieve review threads older than the final 100.

## Issue Context
Request the appropriate page cursor and provide executable pagination/aggregation commands so all review threads needed to correlate prior findings are available.

## Fix Focus Areas
- skills/pr-review/github/SKILL.md[70-93]
- skills/pr-review/github/SKILL.md[101-105]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

authorAssociation
body createdAt path diffHunk
line originalLine startLine originalStartLine
reactionGroups{ content reactors(first:10){ totalCount nodes{ ... on User { login } } } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

9. Trusted reactors are truncated 🐞 Bug ☼ Reliability

The query returns only the first ten reactors for each reaction group and provides no cursor or
follow-up fetch. A trusted maintainer's 👎 outside that slice is invisible, so a valid dismissal is
not honored even though totalCount indicates additional reactors exist.
Agent Prompt
## Issue description
Only ten reactor identities are available for trust evaluation.

## Issue Context
When the thumbs-down group's total exceeds the returned node count, paginate its reactors and aggregate all logins before applying the trust gate.

## Fix Focus Areas
- skills/pr-review/github/SKILL.md[82-111]
- skills/pr-review/SKILL.md[248-263]
- skills/pr-review/SKILL.md[289-296]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

…iliation

Five of the eight findings on this PR were real. Taking them in order of
how badly they broke things.

The GitLab guard did not guard. Step 2a-1 said it skips when the forge
skill "has no Review thread dismissals section" — and then gitlab/SKILL.md
was given a section with exactly that heading to explain that GitLab is
unimplemented. So the check passed, and a GitLab re-review would have
entered the mechanism looking for commands that are not there. The
condition now keys on whether the forge supplies a fetch, not on whether a
heading exists, and the GitLab section says in its first line that it
supplies none. Self-inflicted in the forge split; the whole point of that
split was that GitLab keeps working.

Thread replies were truncated without anyone noticing. comments(first:50)
has to stay `first` so nodes[0] is the root, but "the most recent
qualifying reply wins" needs the tail, and on a thread past 50 comments
the tail is exactly what is missing — including a reversal like "actually,
let's fix this after all". The query now selects comments.pageInfo, and a
thread whose own hasNextPage is true is treated as undetermined and
dismisses nothing. Acting on a truncated view could have honored a
dismissal the author had already withdrawn.

File + category could silence a finding nobody dismissed. One file often
holds several findings of one category; dismissing one of them matched all
of them, because the "is the code still present" check asks about the file,
not about the finding. A match now also requires the finding to be about
the dismissed code — the recorded snippet covers its location, or it
describes the same defect in the same construct — and anything that cannot
be established that way is emitted normally. This one is inherited from
fullsend-ai#709 rather than introduced here, but it is the failure that ends in a
wrongly clean approval.

Disputing a high-severity defect twice stopped it blocking. The
engage-once rule downgraded anything below critical once the single
exchange was spent, so a real high finding could stop blocking without
ever being refuted — the ping-pong cure reintroducing the disease one
notch down. The stop now ends the argument, not the finding: critical and
high keep their severity however often they are disputed, only medium and
below downgrade, and a reply that actually refutes is honored at any
severity because refutation is judged on the code and is never used up.

The no-slug fallback pointed at nothing. It said to fall back to "the
configured review-app login" without naming a source, which is not
actionable in the one case it exists for. Since there is no reliable way
to tell this agent's threads from another bot's without the slug, that
case now skips the step. harness/review.yaml declares
slug: fullsend-ai-review, so it is always set here.

Not changed, with reasons:

- reactors is correct; the finding has it backwards. `users` is the
  older ReactionGroup field, deprecated in favor of reactors — its own
  deprecation notice says "Use the `reactors` field instead" — and
  hidden from default introspection, which is likely where the
  confusion came from. The query as committed executes cleanly against
  this repo's own review threads, including the threads on this very
  PR. Nothing to fix.
- reviewThreads(last:100) and reactors(first:10) both truncate, and both
  fail closed: a dismissal that is not read is a dismissal not honored,
  and the finding is emitted. The pagination note already says a thread's
  absence must not be read as the absence of a dismissal. Fail-open
  truncation would deserve a fix; this does not.

Signed-off-by: guy oron <goron@redhat.com>
Comment thread skills/pr-review/SKILL.md
any other re-review finding. Someone who dismissed one version of the
code was not asked about a different version of it.

This does not suppress the finding — it stays visible to human reviewers

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — "Does not suppress the finding" is false under the shipped defaults — every downgrade path targets info, which three layers delete

Line 1287 states "This does not suppress the finding — it stays visible to human reviewers at low urgency." Verified against the head tree, that is untrue for the fleet's default configuration, and every new downgrade path in this PR targets info: dismissed-and-unchanged (1278-1281), refuted dispute (1310-1311), and the engage-once stop for medium-and-below (1327-1328).

Three independent layers strip info:

  1. harness/review.yaml:59 and :63 set REVIEW_FINDING_SEVERITY_THRESHOLD: "low" for both runner and sandbox.
  2. agents/review.md:65 instructs "Suppress findings below the threshold — do not mention them in the review body and do not include them in the findings array".
  3. scripts/post-review.src.sh:136-151 re-filters with info=0 >= threshold low=1 → false.

Two verified consequences:

(a) The dismissal annotation "Previously raised and dismissed by @<login> (<signal kind>) — retained at info severity…" never reaches the PR at all, so a dismissal becomes silent deletion rather than the visible-at-low-urgency outcome the safety argument at 1287-1291 rests on.

(b) The engage-once stop at 1318-1321 self-destructs: prior findings are parsed in step 2a from /sandbox/workspace/prior-review.txt (SKILL.md:186-192), i.e. the posted body. A medium finding downgraded to info is stripped from that body, so on the next round the orchestrator cannot find "Author's justification considered:" in the prior text, re-raises the finding fresh at medium, and the cycle repeats — the exact #106 ping-pong, now oscillating and driven from a path that is deliberately not trust-gated (1304-1307).

Distinct from the already-posted "Repeated dispute silences defects" comment (id 3871209957), which argues the downgrade policy is wrong; this is that the chosen severity level is filtered out by the pipeline, so neither the visibility promise nor the marker-persistence mechanism can work as written.

Also note scripts/post-review.src.sh:154-172: if filtering empties findings[] on a request-changes verdict, it downgrades to comment, not approve — a third outcome the skill text does not describe.

Suggestion: Use low, not info, on all three paths. Step 6f already treats "Low or info findings only (no medium+) → approve", so low yields the identical non-blocking verdict while surviving the default threshold — keeping the dismissal annotation on the PR and keeping the "Author's justification considered:" marker in prior-review.txt so the stop condition can actually fire next round. If info is deliberate, replace the sentence at 1287 with an explicit statement that at any threshold above info the finding is removed from the posted review entirely, and delete the marker-detection rule at 1318-1321, which cannot work.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9c8cfa3 — all three downgrade paths now target low, the stop rules explicitly keep their markers in the emitted description so they survive the posted-body → prior-review round trip, and the threshold dependence is documented. Took the suggestion as-is.

Comment thread skills/pr-review/SKILL.md Outdated
2. Thread resolvers and reactors carry no association of their own. Look
their login up in the associations already returned for this PR's
thread comments — someone who resolves a thread has usually also
written in it, or elsewhere on the PR — and apply the same tier.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — Trust lookup tells the agent to search "elsewhere on the PR", but the query never fetches PR-level comments or review bodies

Step 2a-1's trust rule 2 (lines 262-265) says resolvers and reactors carry no association of their own, so "Look their login up in the associations already returned for this PR's thread comments — someone who resolves a thread has usually also written in it, or elsewhere on the PR."

The committed GraphQL query at skills/pr-review/github/SKILL.md:71-92 selects only reviewThreads.nodes.comments. PR-level issue comments and review summary bodies — both of which do carry authorAssociation — are never requested. So "or elsewhere on the PR" points at data the agent does not have, and an LLM following it either hallucinates a lookup or issues an unspecified extra call.

The effectiveness gap is structural, not a wording nit: the signal table at 297-301 describes thread resolution as "what a maintainer reaches for when a finding is not worth a sentence," while rule 2 then requires that same maintainer to have written a sentence in some thread, and rule 3's collaborator fallback is documented (274-278) as generally 403ing in the sandbox. Resolution-only and reaction-only dismissals from anyone who never commented inline therefore fall through to "not trusted".

The limitation paragraph at 278-284 attributes this to private orgs where admins report as CONTRIBUTOR, which understates it — it also hits public-org MEMBER resolvers who simply resolved without replying. (One reviewer reported measuring roughly 39% of resolutions unhonored across this repo's last 30 PRs; that measurement was not independently reproduced here and is cited only as the reviewer's own claim.)

Suggestion: Either extend the same query with comments(last:100) and reviews(last:100) association nodes so the lookup matches its own prose (one round trip, no extra cost), or correct line 265 to say the lookup is limited to associations returned for this PR's review-thread comments. Re-frame 278-284 to state the common case up front: resolution-only and reaction-only dismissals are honored only when the actor also commented inline — which is what scopes the runner-side trust-resolution follow-up.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9c8cfa3 — the query now also fetches PR-level comments and reviews associations in the same request, and the limitation text states the residual gap up front. Verified live on this PR: a review-body-only MEMBER now resolves.

Comment thread skills/pr-review/SKILL.md
`category`, the dismissed code itself (the anchor line(s) from the root
comment's hunk, trimmed to the flagged line and a line or two of
surrounding context), the signal kind (`reply`, `resolved`, or
`thumbs-down`), the dismisser's login, and a short excerpt of the decline

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — 2a-1 → 6e data contract is incomplete in both directions: disputed replies have no carrier, and the recorded decline excerpt is never consumed

Step 2a-1 defines exactly one hand-off structure, DISMISSED_FINDINGS (line 321), and grep over the head file shows it is referenced only at 321, 1254, 1257, and 1306. Two gaps follow.

  1. Disputed replies have no carrier. Lines 304-306 say a reply disputing correctness "is handled on its merits in step 6e" and 1306 says it "never reaches DISMISSED_FINDINGS", but nothing instructs the orchestrator to record disputed replies in any named variable, to preserve the $DISMISSALS payload, or to re-fetch thread data at 6e. Step 6e sits ~980 lines later and is told to "Evaluate it against the diff and the source at the PR head" (1308) with no defined input, so the engage-once machinery at 1318-1331 can easily never see the thread it exists to handle.

  2. The reverse direction. Line 325 records "a short excerpt of the decline reply when there was one" and asserts "This feeds into step 6e", but the entire 6e dismissal subsection (1251-1334) uses only file, category, the recorded snippet, the login, and the signal kind — the excerpt is never read or emitted. It is a declared field with no consumer, which is also why the prepended annotation at 1280-1281 carries no reason text.

Suggestion: Define an explicit DISPUTED_FINDINGS (or equivalent) structure populated in 2a-1 alongside DISMISSED_FINDINGS — thread identity, matched prior finding, the reply text, its author — and name it as 6e's input at 1304. Either give the decline excerpt a consumer in 6e (e.g. include it in the prepended annotation) or drop it from the 2a-1 record and the "feeds into step 6e" claim.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9c8cfa3DISPUTED_FINDINGS is recorded in 2a-1 and named as 6e's input, and the decline excerpt now has its consumer (Dismissal note: in the annotation), sanitized at recording since it enters the posted body.

Comment thread skills/pr-review/SKILL.md
cache the result:

1. When the signal carries an author association — replies do — accept
`OWNER`, `MEMBER`, or `COLLABORATOR`, the same tier

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — Trust tier accepts MEMBER, which is org membership, not the "authority over the repo" the rule claims to require

The trust boundary is stated at 254-255 as "a dismissal counts only from someone other than the PR author who holds authority over the repo," but rule 1 at 258-261 accepts OWNER, MEMBER, or COLLABORATOR from authorAssociation. GraphQL's MEMBER means the author is a member of the organization that owns the repository — it carries no repo-level write implication. The cited precedent (.github/scripts/check-e2e-authorization.sh) gates who may trigger an e2e run, a different risk shape from retiring a review finding.

Concretely: an org member with read-only access to this repo cannot resolve a thread (GitHub requires write for that), but a plain reply — "pre-existing pattern, out of scope" — is a qualifying signal under the table at 295-301, and only critical findings are carved out at 1293. So a non-write org member can take a HIGH finding out of the blocking set. Combined with the info-threshold finding on line 1287, the result is not even annotated on the PR.

Suggestion: Either tighten the prose at 254-255 to say what the mechanism actually requires (org membership or repo collaboration, not repo authority), or tighten the mechanism for high-severity findings — require a resolver/collaborator-confirmed write+ role, or require a written reply rather than a bare reaction, before a high finding can be dismissed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9c8cfa3 — the boundary prose now says org member / repo collaborator (what the tier actually attests), and high findings dismiss only by written reply; resolution or 👎 alone no longer qualify. Critical unchanged.

…t gate to its data

Addresses waynesun09's review — all four findings, each verified against
the head tree before changing anything.

The high one first: every downgrade path in this PR targeted `info`, and
`info` does not survive the fleet. REVIEW_FINDING_SEVERITY_THRESHOLD
defaults to "low" on both runner and sandbox, agents/review.md tells the
agent to suppress below-threshold findings from the body and the findings
array, and post-review.src.sh re-filters with the same ordinals
(info=0 < low=1). So "retained at info severity" was retained nowhere:
the annotation never reached the PR, and — the sharper half of the
finding — the prior-review context is rebuilt each round from the posted
body, so the "Author's justification considered:" marker vanished with
it and the engage-once stop could never fire. A medium finding disputed
once would re-raise fresh at medium every round: the fullsend-ai#106 ping-pong,
rebuilt inside its own fix, driven from the one path that is
deliberately not trust-gated.

All three paths now target `low`, which survives the default threshold
and still resolves to the same non-blocking verdict in 6f. The skill now
says why, names the threshold dependence (a repo filtering at
medium-or-above trades away this step's round-to-round memory), and the
stop rules explicitly keep their markers in the emitted description —
a marker that does not reach the posted body does not exist next round.
A prior "Author's justification accepted:" marker is honored the same
way instead of being re-litigated.

The trust gate now matches its data, in both directions. The prose
claimed "authority over the repo" while accepting MEMBER, which attests
org membership, not write access — the boundary is now named honestly,
and a high finding dismisses only by written reply: a resolution or a 👎
is a one-click signal with no stated reason, and membership alone should
not be able to silently take a high finding out of the blocking set.
Critical stays critical regardless, as before.

In the other direction, the lookup rule pointed at data the query never
fetched ("or elsewhere on the PR"). The query now fetches it: PR-level
comments and review bodies carry authorAssociation, so resolvers and
reactors who wrote anywhere on the PR are tierable in the same single
request. Verified live against this PR itself — waynesun09 appears only
as a review body, no issue comment, no thread reply, and the lookup now
resolves him to MEMBER; before this change a resolution-only dismissal
from exactly this shape of reviewer fell through to the 403. The
limitation paragraph now states the residual gap up front — an actor who
never wrote anything on the PR still fails closed — rather than framing
it as a private-org corner case.

And the 2a-1 → 6e contract is closed in both directions. Disputed
replies get a named carrier, DISPUTED_FINDINGS, recorded alongside
DISMISSED_FINDINGS and declared as the disputed-findings rule's input —
previously 6e was told to evaluate a reply that nothing had preserved.
The decline excerpt now has its consumer: it is quoted in the posted
annotation as a "Dismissal note", and is sanitized at recording time
(control characters stripped, the pipeline's own sentinels redacted,
capped) because it is participant text entering the bot's own posted
body — the one place a forged **Head SHA:** marker would be read back as
pipeline state next round. Dispute replies are never quoted into
findings at all; the appended sentence paraphrases.

Query re-verified end to end after the extension: executed verbatim from
this file against PR fullsend-ai#1066, exit 0, thread fields intact, both new
connections returning associations.

Signed-off-by: guy oron <goron@redhat.com>
@rh-hemartin

Copy link
Copy Markdown
Member

@ralphbean I guess your input is needed here

@ralphbean ralphbean self-assigned this Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants