Skip to content

feat(review): preserve findings across review reruns - #2722

Merged
IsmaelMartinez merged 13 commits into
The-PR-Agent:mainfrom
yefuyou:feature/review-finding-lifecycle
Sep 5, 2026
Merged

IsmaelMartinez merged 13 commits into
The-PR-Agent:mainfrom
yefuyou:feature/review-finding-lifecycle

Conversation

@yefuyou

@yefuyou yefuyou commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • preserve structured review findings across persistent /review reruns
  • reconcile findings across runs using existing fingerprint primitives
  • persist active and resolved finding state in the persistent review comment
  • resolve findings only after complete and valid full-scope reviews
  • preserve active findings during incremental and token-limited reviews
  • verify persistent review authorship before trusting stored lifecycle state
  • fail closed to a standalone review when authorship cannot be established
  • avoid duplicate authoritative comments when persistent updates fail
  • normalize provider comment ordering for lifecycle lookups

Design notes

Resolution is deliberately conservative.

Findings are only marked resolved after a complete, valid, full-scope review. Incremental reviews, partial analysis, malformed predictions, provider failures, and degraded persistence do not trigger negative state transitions.

Persistent lifecycle state is trusted only when the existing review comment can be verified as authored by PR-Agent. Hidden markers identify the comment type, but are not treated as authentication.

If authorship cannot be verified, /review still produces a visible standalone review without adopting or modifying an untrusted canonical comment.

Provider ordering is explicit for persistent-comment lookup. Azure DevOps comments and replies are normalized using timestamp-based chronology rather than relying on raw API response order.

Lifecycle persistence also degrades safely when state grows too large: preserving the human review takes priority, and degraded runs do not silently resolve active findings.

Upstream integration

This branch has been rebased onto the latest main used for this update and incorporates the relevant upstream contracts from:

The PR keeps those upstream contracts rather than reintroducing overlapping behavior.

Validation

Final validation after addressing the provider fallback regression and the same-HEAD false-resolution edge case:

  • focused lifecycle/provider tests: 527 passed
  • full unit suite: 3586 passed, 1 skipped, 1 xfailed, 1 failed
  • the single failing test is the unrelated Grok/LiteLLM reasoning_effort capability case and was reproduced independently on clean upstream main
  • git diff --check: passed
  • GitHub Build-and-test: passed
  • GitHub CodeQL: passed

An additional red-team pass found that a complete rerun on the same commit could incorrectly resolve an ACTIVE finding when model output omitted it nondeterministically.

Resolution now requires both a complete valid full review and a known changed HEAD. Same-HEAD and missing-HEAD reruns preserve ACTIVE findings conservatively.

A real GitHub + real-model smoke test was also completed on yefuyou/pr-agent#2:

  • canonical persistent review comment remained 5536025289
  • deliberate finding became ACTIVE
  • same-HEAD rerun preserved ACTIVE
  • fixing the issue on a new HEAD transitioned it to RESOLVED
  • same fixed-HEAD rerun preserved RESOLVED
  • no duplicate canonical or standalone review comments were created
  • persisted lifecycle markers remained valid and parseable throughout

Current PR head:

cc6081822901aa0d9d21e549af8c30b06c3893bd

AI disclosure

This PR was developed with assistance from Codex and validated with targeted tests, full unit-suite runs, upstream CI, and manual contract review by the contributor.

Closes #2453

@github-actions github-actions Bot added the feature 💡 label Aug 20, 2026
Comment thread pr_agent/algo/review_finding_state.py Fixed
@yefuyou
yefuyou force-pushed the feature/review-finding-lifecycle branch from 75348a5 to d9a235a Compare August 20, 2026 13:15
@yefuyou
yefuyou marked this pull request as ready for review August 20, 2026 13:33
@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Preserve review findings across reruns via persistent comment state

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Persist structured review findings across repeated /review runs.
• Reconcile findings by fingerprint; resolve only after complete full-scope reviews.
• Prevent duplicate persistent comments when stateful updates fail.
Diagram

graph TD
  A["/review run (PRReviewer)"] --> B["Load settings"] --> C["Fetch prior review comment"] --> D["Parse hidden state marker"] --> E["Reconcile findings"] --> F[("Persistent review comment")]
  A --> G["Publish review (stateful)"] --> F
  C --> H["Git provider: get_issue_comments"]
  G --> I["Git provider: edit_comment"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Store state outside the PR comment (labels/check-run/DB)
  • ➕ Avoids embedding JSON in markdown and marker parsing
  • ➕ Potentially cleaner separation of display vs state
  • ➕ Can support richer querying/analytics
  • ➖ Requires provider-specific APIs and permissions
  • ➖ Harder to make portable across GitHub/GitLab/etc.
  • ➖ May not be available in self-hosted or restricted environments
2. Resolve via provider-native threads / inline comment lifecycle
  • ➕ Aligns with platform UX (resolved threads, per-line context)
  • ➕ No custom schema/versioning needed
  • ➖ Findings are summary-level today; mapping reliably to threads is non-trivial
  • ➖ Provider capabilities differ significantly; portability suffers
  • ➖ Doesn’t address rerun stability for summary-only findings
3. Persist only active findings; omit resolved history entirely
  • ➕ Simpler state model and less comment bloat
  • ➕ Avoids retention policies and resolved rendering
  • ➖ Loses useful audit trail and reopen signals
  • ➖ Harder to explain why a finding disappeared after a full review

Recommendation: The PR’s approach (hidden, versioned state marker inside the persistent review comment) is the best portability/complexity tradeoff for cross-provider support. The conservative resolution gate (only on complete full-scope reviews) and fail-closed parsing mitigate incorrect state transitions and duplicate-comment risks.

Files changed (7) +971 / -10

Enhancement (2) +463 / -8
review_finding_state.pyAdd versioned persistence + reconciliation for review findings +296/-0

Add versioned persistence + reconciliation for review findings

• Introduces a deterministic, versioned state marker stored in the persistent review comment. Normalizes findings, fingerprints them, reconciles active/resolved/reopened lifecycle, and renders a collapsed 'Resolved findings' section while retaining bounded history.

pr_agent/algo/review_finding_state.py

pr_reviewer.pyWire finding lifecycle into /review generation and publishing +167/-8

Wire finding lifecycle into /review generation and publishing

• Loads prior persistent review state, validates structured findings, and reconciles lifecycle using fingerprints. Publishes even when the textual review has no suggestions if the finding state changed, and disables fallback publishing when updating the persistent comment with state.

pr_agent/tools/pr_reviewer.py

Bug fix (1) +4 / -2
git_provider.pyAdd no-fallback mode for persistent comment updates +4/-2

Add no-fallback mode for persistent comment updates

• Extends persistent comment publishing to optionally skip fallback comment creation on update errors. This prevents duplicate persistent review comments when stateful lifecycle updates rely on editing the existing comment.

pr_agent/git_providers/git_provider.py

Tests (3) +503 / -0
test_pr_reviewer_finding_state.pyAdd PRReviewer integration tests for stateful publishing behavior +125/-0

Add PRReviewer integration tests for stateful publishing behavior

• Validates that resolved findings render into the review output and that state transitions trigger publishing even when 'No major issues detected'. Also verifies fail-closed behavior when state is blocked.

tests/unittest/test_pr_reviewer_finding_state.py

test_review_finding_persistence.pyTest persistence gating and no-fallback publishing semantics +87/-0

Test persistence gating and no-fallback publishing semantics

• Covers fail-closed handling for malformed structured findings, ensures no fallback comment is created after edit failures in stateful mode, and verifies the feature is disabled for generic persistent publishers.

tests/unittest/test_review_finding_persistence.py

test_review_finding_state.pyAdd unit tests for reconciliation, parsing, and retention rules +291/-0

Add unit tests for reconciliation, parsing, and retention rules

• Exercises identity stability under normalization, conservative resolution rules, reopen metadata, deterministic marker round-tripping, invalid marker fail-closed behavior, and resolved retention limits while preserving active findings.

tests/unittest/test_review_finding_state.py

Other (1) +1 / -0
configuration.tomlAdd persistent_finding_state configuration flag +1/-0

Add persistent_finding_state configuration flag

• Adds a pr_reviewer setting to enable/disable persisting review finding lifecycle across reruns. Defaults to enabled alongside persistent_comment.

pr_agent/settings/configuration.toml

@qodo-code-review

qodo-code-review Bot commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Code Review by Qodo

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

Grey Divider


Action required

1. Gitea comments shape unchecked ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
GiteaProvider.get_issue_comments() now only checks for None; if the API returns a non-list
truthy payload (e.g., an error dict), callers that reverse/iterate the result will mis-handle it and
may skip updating the persistent comment or publish duplicates.
Code

pr_agent/git_providers/gitea_provider.py[R639-641]

+        if comments is None:
            self.logger.error("Failed to get comments")
-            return []
+            raise RuntimeError("Failed to get comments")
Relevance

●● Moderate

Similar Gitea payload-shape validation issues were accepted before, but this exact check-only-None
pattern is untested.

PR-#2569
PR-#2142

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
get_issue_comments() now raises only for None and otherwise returns the raw payload. Persistent
publishing logic immediately materializes and reverse-iterates the returned value, which will behave
incorrectly if it’s not a list of comments (e.g., iterating dict keys). This is a known Gitea
integration failure mode in this codebase.

pr_agent/git_providers/gitea_provider.py[631-643]
pr_agent/git_providers/git_provider.py[377-382]
PR-#2569

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

### Issue description
`GiteaProvider.get_issue_comments()` returns `comments` without validating its type. Downstream code assumes an iterable of comment objects/dicts; if Gitea returns an error object (dict) or other unexpected payload, the persistent update logic can iterate keys instead of comments and fail to locate/update the existing persistent review.

### Issue Context
This repo has had prior issues where Gitea endpoints returned unexpected payload shapes and required explicit type validation.

### Fix Focus Areas
- pr_agent/git_providers/gitea_provider.py[631-643]

### Suggested fix approach
- Change the guard to:
 - `if comments is None: raise ...`
 - `if not isinstance(comments, list): raise RuntimeError(f"Unexpected comments payload type: {type(comments)}")`
- Optionally filter/validate each element is a dict-like comment (or tolerate both dict/object shapes consistently).
- Add/extend a unit test that simulates `list_all_comments` returning a dict error payload and assert `get_issue_comments()` raises.

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


2. Gitea drops persistent reviews ✓ Resolved 🐞 Bug ≡ Correctness
Description
The invalid-marker recovery path forces fallback_on_error=False, but Gitea's
get_issue_comments() returns dictionaries while the generic persistent publisher dereferences
comment.body. On any PR with an existing Gitea comment this raises, is swallowed by
publish_persistent_comment_full, and returns without editing or creating the review, so the
lifecycle update and the current review are lost.
Code

pr_agent/tools/pr_reviewer.py[R226-233]

+                persistent_args = dict(
+                    initial_header=f"{PRReviewHeader.REGULAR.value} 🔍",
+                    update_header=True,
+                    final_update_message=False,
+                    fallback_on_error=False,
+                    **review_thread_kwargs,
+                )
+                self.git_provider.publish_persistent_comment_full(pr_review, **persistent_args)
Relevance

●●● Strong

Concrete provider response-shape bug risking silent loss of persistent review updates; similar
lifecycle bugs fixed before.

PR-#2404
PR-#2599

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new branch invokes the generic full publisher with fallback disabled. Gitea declares its issue
comments as dictionaries, whereas the generic publisher reads an object body; its broad exception
handler returns None when fallback is disabled, making the failure silent and preventing
publication.

pr_agent/tools/pr_reviewer.py[226-233]
pr_agent/git_providers/gitea_provider.py[625-637]
pr_agent/git_providers/git_provider.py[372-400]

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 stateful persistent-review path uses `fallback_on_error=False`, but the generic publisher assumes object-style comments. Gitea supplies mapping records, causing the update to fail and publish nothing.

## Issue Context
Gitea is considered state-capable because its provider reports `get_issue_comments` support and overrides the persistent publisher, but its returned comments must be normalized or handled by a Gitea-specific persistent update implementation.

## Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[226-233]
- pr_agent/git_providers/git_provider.py[372-400]
- pr_agent/git_providers/gitea_provider.py[625-637]

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


3. State block causes duplicate comments ✓ Resolved 🐞 Bug ☼ Reliability
Description
When review finding state is blocked (malformed marker or read error), PRReviewer.run() publishes a
new non-persistent comment instead of updating the persistent review comment, leaving the malformed
marker in place. This creates repeated duplicate review comments across reruns and prevents the
system from self-healing by overwriting/removing the bad marker.
Code

pr_agent/tools/pr_reviewer.py[R214-219]

+            if state_blocked:
+                get_logger().warning(
+                    "Review finding state is unavailable; publishing this review without persistent state"
+                )
+                self.git_provider.publish_comment(pr_review, **review_thread_kwargs)
+            elif get_settings().pr_reviewer.persistent_comment and not self.incremental.is_incremental:
Relevance

●●● Strong

Persistent-comment fallback paths causing duplicate comments were explicitly accepted as reliability
fixes.

PR-#2404

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The PR introduces a state-blocked branch that explicitly publishes non-persistently, and
_load_review_finding_state sets the block flag when the marker is invalid or unreadable—so the
invalid marker is never overwritten and the behavior repeats every run.

pr_agent/tools/pr_reviewer.py[214-233]
pr_agent/tools/pr_reviewer.py[263-280]
PR-#2404

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

### Issue description
If persistent finding state cannot be parsed/loaded, the code sets `_review_state_blocked=True` and then `run()` publishes via `publish_comment()` (non-persistent). Because the persistent comment is never edited, the malformed marker remains forever and every rerun continues posting new comments.

### Issue Context
The intended behavior (per PR description) is to avoid duplicate persistent comments when lifecycle state updates fail. The current blocked-state branch does the opposite by always creating new comments and never clearing the invalid marker.

### Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[214-233]
- pr_agent/tools/pr_reviewer.py[263-280]

### Proposed fix
- In `run()`, when `state_blocked` is True:
 - Still publish using the persistent mechanism (`publish_persistent_comment_full` or `publish_persistent_comment`) **without** appending any state marker/section.
 - This will overwrite the malformed marker (self-heal) and prevent duplicate review comments.
- Keep lifecycle reconciliation disabled for that run (i.e., don’t compute state transitions), but do not switch to non-persistent publishing.

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


View high (2)
4. Loads stale review state ✓ Resolved 🐞 Bug ≡ Correctness
Description
_load_review_finding_state() returns the first matching review comment from get_issue_comments(),
which can select an older/stale review when multiple PR-Agent review comments exist, causing
reconciliation to use the wrong baseline state. This can incorrectly resolve/reopen findings and
keep lifecycle state out of sync with the latest persistent review comment.
Code

pr_agent/tools/pr_reviewer.py[R266-270]

+            for comment in self.git_provider.get_issue_comments():
+                body = getattr(comment, "body", "")
+                if not isinstance(body, str) or not body.startswith(header):
+                    continue
+                parsed = parse_review_state(body)
Relevance

●●● Strong

A recent precedent explicitly accepted reversing issue-comment iteration to select the newest review
state.

PR-#2381
PR-#2599

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new state loader returns the first header-matching comment in provider iteration order, which is
commonly oldest-first; past bugs show this pattern picks stale reviews when multiple exist.

pr_agent/tools/pr_reviewer.py[263-276]
PR-#2381

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

### Issue description
`PRReviewer._load_review_finding_state()` scans `get_issue_comments()` in forward order and returns on the first match. If multiple PR-Agent review comments exist (e.g., from prior failures/duplicates), this can load an older comment’s embedded state and reconcile against stale data.

### Issue Context
This PR introduces persistent finding lifecycle state that depends on reading the latest persistent review comment’s marker. Picking an older match can regress state and produce wrong resolved/reopened transitions.

### Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[263-281]

### Proposed fix
- Iterate comments in reverse chronological order when searching for the persistent review comment:
 - Prefer `for comment in reversed(list(self.git_provider.get_issue_comments())):` (or select the max by a timestamp field when available).
- Keep the existing header filter, but ensure the *newest* matching comment is parsed and used as the previous state baseline.

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


5. Gitea edit failures bypass fallback ✓ Resolved 🐞 Bug ☼ Reliability
Description
When editing a dict-shaped Gitea comment fails, GiteaProvider.edit_comment catches the exception
and returns without raising, while publish_persistent_comment_full treats the call as successful
because it does not inspect the return value. As a result, normal persistent updates cannot fall
back to publishing a new comment after an edit failure, so the review update is lost.
Code

pr_agent/git_providers/gitea_provider.py[R359-362]

+        if isinstance(comment, dict):
+            comment_id = comment.get("comment_id") or comment.get("id")
+        else:
+            comment_id = getattr(comment, "id", None)
Relevance

●● Moderate

PR intentionally disables fallback on stateful updates to avoid duplicate comments; edit failures
already surfaced via tests/logging.

PR-#2492
PR-#2424

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed Gitea code extracts a dictionary comment ID and then catches API failures, while the
generic publisher only falls back on an exception; therefore a failed dict-comment edit is returned
as if successful.

pr_agent/git_providers/gitea_provider.py[359-367]
pr_agent/git_providers/gitea_provider.py[363-375]
pr_agent/git_providers/git_provider.py[379-408]
tests/unittest/test_review_finding_persistence.py[158-176]

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

## Issue description
Gitea edit failures are swallowed by `GiteaProvider.edit_comment`, so `GitProvider.publish_persistent_comment_full` cannot detect the failure and execute its fallback publishing path.

## Issue Context
The generic publisher only enters fallback handling when `edit_comment` raises. The changed dict-ID path is used for Gitea comments represented as dictionaries, and the existing persistence tests cover exception propagation with a mock but not Gitea's swallowed API exception.

## Fix Focus Areas
- pr_agent/git_providers/gitea_provider.py[359-362]
- pr_agent/git_providers/gitea_provider.py[363-375]
- pr_agent/git_providers/git_provider.py[380-408]
- tests/unittest/test_review_finding_persistence.py[158-176]

Make failed Gitea edits propagate an exception (or otherwise provide an explicit failure signal that the generic publisher handles), preserving the no-fallback behavior when `fallback_on_error=False` and the fallback behavior when it is true. Add a regression test using `GiteaProvider.edit_comment` with a failing API call and verify the generic publisher publishes a replacement only when fallback is enabled.

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



Remediation recommended

6. Marker text breaks parsing ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
append_review_state() renders resolved finding bodies verbatim, so if any finding body contains
the marker namespace string (<!-- pr-agent-review-state), the next run’s parse_review_state()
will see multiple namespaces and mark state invalid, disabling lifecycle reconciliation.
Code

pr_agent/algo/review_finding_state.py[R304-308]

+    human_body = "\n\n".join(
+        section
+        for section in (body, _render_resolved_section(state))
+        if section
+    )
Relevance

●● Moderate

Valid edge case but marker-poisoning issues from user content are subtle; no exact precedent found.

PR-#2424

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The resolved findings section prints finding["body"] verbatim, and append_review_state() appends
that section alongside the hidden marker. parse_review_state() declares the state invalid whenever
the comment contains the marker namespace substring more than once, so a single finding body
containing that substring will poison future parsing.

pr_agent/algo/review_finding_state.py[267-291]
pr_agent/algo/review_finding_state.py[294-325]
pr_agent/algo/review_finding_state.py[135-143]

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

### Issue description
Persisted review state can become permanently "invalid" if a finding body (shown in the resolved section) happens to contain the marker namespace string `<!-- pr-agent-review-state`. This makes `parse_review_state()` treat the comment as having multiple markers and fail closed, blocking lifecycle updates.

### Issue Context
- `_render_resolved_section()` includes `finding["body"]` directly.
- `parse_review_state()` uses a raw substring count (`body.count(_STATE_MARKER_NAMESPACE)`) to validate there is exactly one marker namespace.

### Fix Focus Areas
- pr_agent/algo/review_finding_state.py[135-143]
- pr_agent/algo/review_finding_state.py[267-291]
- pr_agent/algo/review_finding_state.py[303-325]

### Suggested fix approach
- Prefer removing the `namespace_count` heuristic and rely on the regex match count alone (i.e., `len(matches)`), or
- Escape/sanitize the namespace string when rendering human-visible bodies (e.g., replace `<!-- pr-agent-review-state` with an entity-encoded or zero-width-joiner variant) so it can never appear verbatim outside the real marker.
- Add a unit test where a finding body contains `<!-- pr-agent-review-state` and ensure parsing still succeeds and state round-trips.

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


7. Long HTML row line ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new persistent_finding_state documentation row is a single physical line exceeding the
120-character maximum. This can violate repository style/lint expectations and reduces readability
of docs diffs.
Code

docs/docs/tools/review.md[63]

+        <td>If set to true, PR-Agent persists structured review finding state across complete review runs, so findings can be resolved and reopened. Incremental and partial reviews do not resolve absent findings. Default is true.</td>
Relevance

●●● Strong

Recent precedent accepts line-length fixes in modified documentation/Python code.

PR-#2318
PR-#2212

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Compliance ID 2694690 requires keeping modified source file lines at or under 120 characters. The
added <td>...</td> line for persistent_finding_state is a long single-line HTML table cell in
the modified docs section.

Rule 2694690: Enforce maximum line length of 120 characters
docs/docs/tools/review.md[63-63]

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 newly added documentation line exceeds the 120-character maximum line length requirement.

## Issue Context
The `persistent_finding_state` configuration row in `docs/docs/tools/review.md` is written as a single long HTML line.

## Fix Focus Areas
- docs/docs/tools/review.md[61-64]

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


8. Single quotes in data.get 📘 Rule violation ⚙ Maintainability
Description
The newly added code uses a single-quoted string literal ('review') where double quotes are
required by the Python string literal convention. This can cause style/lint failures or inconsistent
formatting.
Code

pr_agent/tools/pr_reviewer.py[472]

+        if not isinstance(data.get('review'), dict):
Relevance

●●● Strong

Trivial deterministic double-quote style fix; matches accepted quoting-style precedent for changed
code.

PR-#2679

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Compliance ID 2694657 requires double quotes for Python string literals in changed code. The newly
added data.get('review') uses a single-quoted string literal without any apparent need to avoid
escaping.

Rule 2694657: Use double quotes for all Python string literals
pr_agent/tools/pr_reviewer.py[472-472]

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 newly added Python string literal uses single quotes where the project requires double quotes.

## Issue Context
In `_prepare_pr_review`, the new `data.get('review')` uses a single-quoted string literal.

## Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[472-472]

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


View medium (4)
9. Non-imperative review state docs ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
Newly added documentation text is written descriptively instead of using imperative phrasing (e.g.,
the module docstring and a comment explaining the generic publisher behavior). This violates the
required docstring/comment style convention and reduces consistency across the codebase.
Code

pr_agent/algo/review_finding_state.py[1]

+"""Persistent state helpers for cross-run review findings."""
Relevance

●●● Strong

Recent precedent explicitly accepted imperative phrasing changes for newly added comments.

PR-#2661

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires imperative phrasing for new/modified docstrings and behavior-describing
comments. The new module docstring is a descriptive noun phrase, and the added comment about the
generic publisher is also descriptive rather than imperative.

Rule 2694688: Docstrings and comments must use imperative phrasing
pr_agent/algo/review_finding_state.py[1-1]
pr_agent/tools/pr_reviewer.py[249-252]

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

## Issue description
New docstrings/comments are not written in imperative mood.

## Issue Context
The repo requires newly added/modified docstrings and behavioral comments to be written as commands (e.g., "Return ...", "Handle ...") rather than descriptive statements.

## Fix Focus Areas
- pr_agent/algo/review_finding_state.py[1-1]
- pr_agent/tools/pr_reviewer.py[249-252]

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


10. _retained_findings return line too long ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The return statement in _retained_findings exceeds the 120-character limit required by the repo’s
Ruff configuration. This can cause lint failures and makes the code harder to read and review.
Code

pr_agent/algo/review_finding_state.py[176]

+    return sorted(active + resolved[:max(0, max_resolved_findings)], key=lambda finding: finding["finding_id"])
Relevance

●●● Strong

Recent precedent accepted fixing overlong Python lines under the repository’s 120-character limit.

PR-#2318
PR-#2381

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires a 120-character max line length in Python files. The new
review_finding_state.py contains a long return statement that exceeds this limit.

Rule 2694655: Enforce 120-character maximum line length in Python source per Ruff config
pr_agent/algo/review_finding_state.py[169-177]

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 newly added Python line exceeds the 120-character max line length.

## Issue Context
Ruff is configured to enforce a 120 character limit; violating lines may fail linting and reduce readability.

## Fix Focus Areas
- pr_agent/algo/review_finding_state.py[169-177]

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


11. persistent_finding_state not in .pr_agent.toml ✓ Resolved 📘 Rule violation ≡ Correctness
Description
The new pr_reviewer.persistent_finding_state flag is present in
pr_agent/settings/configuration.toml but is not reflected in the root .pr_agent.toml. This
violates the configuration sync requirement and can lead to inconsistent defaults/configuration
sources.
Code

pr_agent/settings/configuration.toml[110]

+persistent_finding_state=true # Persist review finding state across complete review runs.
Relevance

●●● Strong

Configuration consistency and single-source-of-truth fixes are repeatedly accepted for newly added
settings.

PR-#2528
PR-#2598

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires keeping behavior-related configuration keys consistent between
.pr_agent.toml and pr_agent/settings/*.toml. The new persistent_finding_state key exists in
the settings configuration but is not present under [pr_reviewer] in .pr_agent.toml.

Rule 2694685: Keep .pr_agent.toml and pr_agent/settings/*.toml configuration in sync on behavior changes
pr_agent/settings/configuration.toml[107-112]
.pr_agent.toml[6-10]

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 new behavior/config flag was added in `pr_agent/settings/` but not mirrored in `.pr_agent.toml` as required.

## Issue Context
The compliance checklist requires keeping `.pr_agent.toml` and `pr_agent/settings/*.toml` aligned for behavior-related configuration keys.

## Fix Focus Areas
- pr_agent/settings/configuration.toml[107-112]
- .pr_agent.toml[6-10]

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


12. persistent_finding_state undocumented in docs ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
A new user-facing configuration option pr_reviewer.persistent_finding_state was added, but the
review tool documentation’s configuration options list does not mention it. This makes the feature
difficult for users to discover or configure correctly.
Code

pr_agent/settings/configuration.toml[110]

+persistent_finding_state=true # Persist review finding state across complete review runs.
Relevance

●●● Strong

Recent documentation precedents accept documenting newly added user-facing configuration and
behavior.

PR-#2528
PR-#2491

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires updating README/docs when user-facing behavior changes. The PR introduces
persistent_finding_state in configuration.toml, but the review tool docs’ configuration table
does not include that option.

Rule 2694680: Update docs when user-facing behavior changes
pr_agent/settings/configuration.toml[107-112]
docs/docs/tools/review.md[52-86]

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 new configuration option was introduced but the user-facing documentation for the `review` tool configuration options was not updated.

## Issue Context
The docs page `docs/docs/tools/review.md` contains the authoritative list of `pr_reviewer` configuration options.

## Fix Focus Areas
- pr_agent/settings/configuration.toml[107-112]
- docs/docs/tools/review.md[52-86]

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



Informational

13. Unsorted imports in review_finding_state 📘 Rule violation ⚙ Maintainability
Description
The standard-library imports in the new module are not alphabetically ordered per isort section
ordering. This can trigger lint/formatting failures and causes unnecessary diff churn.
Code

pr_agent/algo/review_finding_state.py[R6-9]

+import json
+import re
+from dataclasses import dataclass
+from datetime import datetime, timezone
Relevance

● Weak

Recent precedent rejected an isort ordering complaint in a modified import block.

PR-#2661

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The compliance rule requires Python imports to be grouped and alphabetically ordered. In the new
module, standard-library imports are not ordered (e.g., from dataclasses ... appears after `import
re`).

Rule 2694656: Group Python imports according to isort sections and order
pr_agent/algo/review_finding_state.py[5-13]

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

## Issue description
Imports are not ordered/grouped according to isort expectations (standard library imports should be alphabetized).

## Issue Context
The repo requires Python imports to follow isort sectioning and ordering.

## Fix Focus Areas
- pr_agent/algo/review_finding_state.py[5-13]

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


14. pr_reviewer imports out of order ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The newly added review_finding_state import is not placed in alphabetical order within the
local-import section. This violates the repo’s isort-style import ordering and may cause
formatting/lint failures.
Code

pr_agent/tools/pr_reviewer.py[R19-22]

+from pr_agent.algo.review_finding_state import (
+    append_review_state,
+    parse_review_state,
+    reconcile_review_findings,
Relevance

● Weak

Recent, closely matching precedent rejected a requested import-ordering correction in modified
Python code.

PR-#2661

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The compliance rule requires local imports to be alphabetically ordered. The new `from
pr_agent.algo.review_finding_state ... import is inserted before from pr_agent.algo.pr_processing
...`, breaking alphabetical ordering.

Rule 2694656: Group Python imports according to isort sections and order
pr_agent/tools/pr_reviewer.py[9-26]

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 newly added import is placed out of order within the local (first-party) imports.

## Issue Context
Imports must be grouped and alphabetically ordered per isort-style conventions.

## Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[9-26]

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


  • Author self-review: I have reviewed the code review findings, and addressed the relevant ones.

Grey Divider

Context sources
✅ Compliance rules (platform): 34 rules
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Finding overflow, which tucks the rest behind 'View more'

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread pr_agent/tools/pr_reviewer.py Outdated
Comment thread pr_agent/tools/pr_reviewer.py Outdated
Comment thread pr_agent/tools/pr_reviewer.py Outdated
@qodo-code-review

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit 2ca9a24

Comment thread pr_agent/git_providers/gitea_provider.py
@qodo-code-review

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit 0b66cd9

Comment thread pr_agent/git_providers/gitea_provider.py Outdated
@qodo-code-review

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit 53f75f8

@qodo-code-review

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit c04cc62

@IsmaelMartinez IsmaelMartinez left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for taking #2453 on, and for turning the Qodo findings around so fast. Four notes inline, two with suggestions; the suite stays green with both applied.

"""Serialize state deterministically so repeated updates are diffable."""
if not _is_valid_state(state):
raise ValueError("Invalid review finding state")
payload = json.dumps(state, ensure_ascii=False, sort_keys=True, separators=(",", ":"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A finding body containing --> closes the marker early and the rest of the payload renders in the review, and parse_review_state still calls that state valid, so it repeats every run. Escaping both brackets fixes it without changing the decoded state.

It does not close Qodo's item 12: append_review_state prints bodies verbatim in the human section too.

Suggested change
payload = json.dumps(state, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
payload = json.dumps(state, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
# '<' and '>' occur only inside JSON strings, so escaping cannot change the decoded state
payload = payload.replace("<", "\\u003c").replace(">", "\\u003e")

Comment on lines +423 to +430
allow_resolution = (
bool(self.prediction)
and not bool(getattr(self.incremental, "is_incremental", False))
and not bool(self.remaining_files_list)
and parsed.valid
and current_findings is not None
and len(current_findings) < max_findings
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

With num_max_findings at its default of 3, two findings enable resolution, and nothing checks that the commit moved. Re-running /review on an unchanged head marks a finding RESOLVED just because the model did not repeat it.

Gate on the previous SHA rather than the current one: _review_head_sha reads last_commit_id, which only GitHub and Gitea set, so testing the current SHA turns two of your own tests red.

Suggested change
allow_resolution = (
bool(self.prediction)
and not bool(getattr(self.incremental, "is_incremental", False))
and not bool(self.remaining_files_list)
and parsed.valid
and current_findings is not None
and len(current_findings) < max_findings
)
previous_head_sha = str(((parsed.state or {}).get("last_run") or {}).get("head_sha") or "")
current_head_sha = self._review_head_sha()
allow_resolution = (
bool(self.prediction)
and not bool(getattr(self.incremental, "is_incremental", False))
and not bool(self.remaining_files_list)
and parsed.valid
and current_findings is not None
and len(current_findings) < max_findings
and (not previous_head_sha or current_head_sha != previous_head_sha)
)

artifact={"error": e})
else:
get_logger().exception(f"Failed to edit github comment", artifact={"error": e})
raise

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not behind persistent_finding_state, and the same line lands in azuredevops_provider.py:237 and bitbucket_provider.py:438, so edit_comment propagates on all three, including the 403 branch this code calls "usually due to polling".

Mostly a gain: a failed edit on the suggestions comment now republishes them where main drops them. It escapes the function only when the fallback write fails too, and then /improve publishes nothing. Both worth a line in the description.

)
except Exception as e:
get_logger().exception(f"Failed to edit comment, error: {e}")
raise

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hard conflict with #2724, which returns False here. Worth agreeing the resolution before either merges, since as noted on github_provider.py the two are not equivalent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hard conflict with #2724, which returns False here. Worth agreeing the resolution before either merges, since as noted on github_provider.py the two are not equivalent.

Agreed. Since #2724 owns the broader Azure path and already validates the False return contract end-to-end, I’ll align #2722 with that behavior rather than keep the competing exception-propagation change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks. I traced the edit_comment() call sites further and narrowed #2722 so the two PRs no longer need to own the same Azure behavior.

#2722 now leaves AzureDevopsProvider.edit_comment() and the /improve path untouched. It only updates the shared persistent-comment publisher to treat an explicit False return as an edit failure, while preserving fallback_on_error=False for lifecycle updates so a failed edit cannot create a duplicate persistent review.

That means #2724 can keep the Azure True/False contract and its /improve caller handling independently.

I also added regression coverage for both paths:

  • False + fallback_on_error=False → no fallback comment
  • False + fallback_on_error=True → normal fallback

The full unit suite is green locally.

@qodo-code-review

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit e97e318

@IsmaelMartinez IsmaelMartinez left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for chasing this down, but the narrowing has not landed on this branch.

AzureDevopsProvider.edit_comment() is still modified here: head e97e3184 adds raise to its except block, which is the line the conflict was about. Merged onto today's main, this and #2724 still collide on azuredevops_provider.py and git_provider.py.

Worth knowing before you change anything: simply dropping the Azure raise would not be safe on its own. On main edit_comment returns None, not False, so the is False guard you added to publish_persistent_comment_full would never fire, and your fallback_on_error=False callers would read a failed edit as success. That only becomes safe once #2724's True/False contract is in.

So this is an ordering question. If #2724 lands first, this can drop the Azure change entirely. If this lands first, #2724 has to adopt the raise instead.

@yefuyou

yefuyou commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for chasing this down, but the narrowing has not landed on this branch.

AzureDevopsProvider.edit_comment() is still modified here: head e97e3184 adds raise to its except block, which is the line the conflict was about. Merged onto today's main, this and #2724 still collide on azuredevops_provider.py and git_provider.py.

Worth knowing before you change anything: simply dropping the Azure raise would not be safe on its own. On main edit_comment returns None, not False, so the is False guard you added to publish_persistent_comment_full would never fire, and your fallback_on_error=False callers would read a failed edit as success. That only becomes safe once #2724's True/False contract is in.

So this is an ordering question. If #2724 lands first, this can drop the Azure change entirely. If this lands first, #2724 has to adopt the raise instead.

You're right. I found the residual change: the Azure raise and its regression test were introduced in an earlier commit and survived my later scope narrowing. My last update only narrowed the newest patch, not the full PR diff against main.

I also understand why simply removing it now would be unsafe while main still returns None on that path. I'll coordinate the merge order: once the Azure True/False contract is established, I'll rebase #2722, remove the Azure-specific change and test, and verify the complete PR diff against main before updating the PR.

@IsmaelMartinez

Copy link
Copy Markdown
Collaborator

Sequencing note: #2797 rewrites the persistent-comment discovery this PR also touches and is likely to land first. Your planned rebase after #2724 would then pick both up in one pass.

@IsmaelMartinez IsmaelMartinez left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: the design is sound but not mergeable yet, because the persisted state is writable by anyone who can comment on the PR. Six notes inline, four with committable suggestions.

Credit where it is due: versioning the marker, treating malformed state as unsafe rather than silently ignoring it, and refusing to resolve findings on incremental or partial runs are all the right calls, and your 64 new tests made every check below cheap for me to run.

The two blockers are the trust boundary in _load_review_finding_state and the unbounded state growth that eventually fails /review outright. The regex ones are one-liners. The comment ordering and the /improve fallout need a decision from you.

Comment thread pr_agent/tools/pr_reviewer.py Outdated
Comment on lines +284 to +292
def _load_review_finding_state(self):
header = f"{PRReviewHeader.REGULAR.value} 🔍"
try:
comments = list(self.git_provider.get_issue_comments())
invalid_marker_found = False
for comment in reversed(comments):
body = GitProvider._get_comment_body(comment)
if not isinstance(body, str) or not body.startswith(header):
continue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Any PR participant can seed the state this reads back. header is the rendered heading, so a comment that starts ## PR Reviewer Guide 🔍 and carries a <!-- pr-agent-review-state:v1 ... --> block is accepted as the agent's own state. I ran it against a fake provider on head e97e3184: parsed.valid is True, and the injected finding body comes back out through _render_resolved_section into the agent's own review comment under "✅ Resolved findings".

Matching only on the hidden marker does not fix it, and I checked before suggesting it. It drops the heading requirement, so a plain comment carrying the marker is accepted where today it is not, and POST /markdown renders the marker to nothing, so the bait is invisible either way. All 52 tests still passed with that change, which is the point: nothing here tests the trust boundary.

What closes it is checking who wrote the comment before trusting its state. github_provider.py:995-1000 already has that pattern, and its deployment_type app-vs-user split is also why this is not a one-liner, so no suggestion from me.

Comment on lines +137 to +143
body = comment_body or ""
namespace_count = body.count(_STATE_MARKER_NAMESPACE)
matches = list(_STATE_MARKER_RE.finditer(body))
if namespace_count == 0:
return ParsedReviewState(None, present=False, valid=True)
if namespace_count != 1 or len(matches) != 1:
return ParsedReviewState(None, present=True, valid=False)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

finditer runs before the uniqueness guard, so a comment full of marker openers is scanned quadratically, and _load_review_finding_state feeds this every comment that starts with the heading, which is the same door as the note on pr_reviewer.py:284.

Timed on head e97e3184 by doubling the input: 4.0x per doubling, 0.22s at GitHub's 65536-char comment cap, 3.48s at 240KB. Moving the scan after the guard makes it linear, 1.9x per doubling and 0.06ms at 240KB, with all 52 tests in the three new files still green.

Suggested change
body = comment_body or ""
namespace_count = body.count(_STATE_MARKER_NAMESPACE)
matches = list(_STATE_MARKER_RE.finditer(body))
if namespace_count == 0:
return ParsedReviewState(None, present=False, valid=True)
if namespace_count != 1 or len(matches) != 1:
return ParsedReviewState(None, present=True, valid=False)
body = comment_body or ""
namespace_count = body.count(_STATE_MARKER_NAMESPACE)
if namespace_count == 0:
return ParsedReviewState(None, present=False, valid=True)
# Scan only once the namespace is unique: the lazy payload is quadratic over repeated openers.
if namespace_count != 1:
return ParsedReviewState(None, present=True, valid=False)
matches = list(_STATE_MARKER_RE.finditer(body))
if len(matches) != 1:
return ParsedReviewState(None, present=True, valid=False)

Comment thread pr_agent/algo/review_finding_state.py Outdated

The optional limit is reserved for the complete hidden marker.
"""
body = _STATE_MARKER_RE.sub("", review_body or "").rstrip()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same regex, second call site, no guard at all. Measured by doubling: 4.2x per doubling, 0.23s at 60KB, 3.91s at 240KB. The input here is the freshly built review markdown rather than a comment, so it is only reachable when the model echoes repeated openers back from the diff, but the guard is free and takes it to 0.1ms at 240KB with the suite green.

Suggested change
body = _STATE_MARKER_RE.sub("", review_body or "").rstrip()
body = review_body or ""
if body.count(_STATE_MARKER_NAMESPACE) == 1:
# Strip only a well-formed marker: the lazy payload is quadratic over repeated openers.
body = _STATE_MARKER_RE.sub("", body)
body = body.rstrip()

Comment thread pr_agent/git_providers/git_provider.py Outdated
prev_comments = list(self.get_issue_comments())
for comment in prev_comments:
if comment.body.startswith(initial_header):
for comment in reversed(prev_comments):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

reversed() picks the oldest comment on Azure DevOps. AzureDevopsProvider.get_issue_comments calls threads.reverse() at azuredevops_provider.py:985-995, so it already returns newest-first and reversing again inverts it. I ran the real method with a stubbed client over three threads: main's for comment in prev_comments picks NEWEST, this picks OLDEST.

test_persistent_update_uses_latest_matching_comment and test_persistent_update_accepts_dict_comments_and_uses_latest still pass, because their fake provider returns oldest-first, so the intent is clear and only Azure comes out backwards. pr_reviewer.py:289 reverses the same list, so on Azure the state is read from the oldest review rather than the latest, which is how a resolved finding comes back.

get_issue_comments carries no ordering in the base class, so this needs either an ordering contract there or normalising inside the Azure provider, neither of which fits a suggestion block.

Comment on lines +309 to +323
marker = serialize_review_state(state)
if max_chars is not None:
if not isinstance(max_chars, int) or max_chars < len(marker) + 1:
raise ValueError(
"Comment limit is too small for the persistent "
"review state marker"
)
human_budget = max_chars - len(marker) - 3
if len(human_body) > human_budget:
if human_budget <= 0:
human_body = ""
elif human_budget < 3:
human_body = human_body[:human_budget]
else:
human_body = human_body[: human_budget - 3] + "..."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The hidden marker is budgeted ahead of the reviewer's text, so the state wins as it grows. Against max_chars=65536: at 60 resolved findings the marker is 61406 chars and the review body is cut to 4127 chars mid-sentence; once the marker passes the cap this raises ValueError, which run() turns into "Failed to review PR" with no way back, since the state lives in the comment and never shrinks.

That is reachable on defaults. allow_resolution requires len(current_findings) < num_max_findings, default 3, so any run that returns 3 findings resolves nothing, and _retained_findings caps RESOLVED only while ACTIVE grows without bound. Simulating repeated 3-finding runs: 135 findings after 45 runs, marker 75700 chars, hard failure.

Failing soft keeps the review. It reds test_append_review_state_reserves_space_for_complete_marker and test_prepare_and_persisted_state_round_trip_preserves_marker_and_history, which encode the opposite trade-off, so which side to keep is your call; the other 50 stay green and the simulation runs to 60 with the review intact.

Suggested change
marker = serialize_review_state(state)
if max_chars is not None:
if not isinstance(max_chars, int) or max_chars < len(marker) + 1:
raise ValueError(
"Comment limit is too small for the persistent "
"review state marker"
)
human_budget = max_chars - len(marker) - 3
if len(human_body) > human_budget:
if human_budget <= 0:
human_body = ""
elif human_budget < 3:
human_body = human_body[:human_budget]
else:
human_body = human_body[: human_budget - 3] + "..."
marker = serialize_review_state(state)
if max_chars is not None:
if not isinstance(max_chars, int) or max_chars < 4:
raise ValueError("Comment limit is too small for a review comment")
if len(human_body) + len(marker) + 3 > max_chars:
# The review is the product: drop the hidden state before the reviewer's own text.
marker = ""
if len(human_body) + 1 > max_chars:
human_body = human_body[: max_chars - 4] + "..."

artifact={"error": e})
else:
get_logger().exception(f"Failed to edit github comment", artifact={"error": e})
raise

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Following up my earlier note here, I was too generous: the default /improve path has an unguarded call site. pr_code_suggestions.py:603 sits after the try/except in publish_persistent_comment_with_history, on the branch taken when no previous suggestions comment exists, which is every first run with persistent_comment=true (the default, configuration.toml:185). The one I flagged before at :273 needs persistent_comment=false.

I ran it with a provider whose edit_comment raises: today the function returns {'id': 1}, with this change the RuntimeError propagates out. run()'s handler at pr_code_suggestions.py:312-317 then removes the progress comment and, because self.progress_response is still set, skips the "Failed to generate code suggestions" fallback, so the user is left with nothing at all instead of a stuck progress note. Worth guarding that call, or catching in the caller, before the raise lands.

alvistar added a commit to alvistar/pr-agent that referenced this pull request Sep 1, 2026
…2510 has landed

The-PR-Agent#2510 was merged upstream while this was being assembled, so the fork no longer
carries it. Only The-PR-Agent#2722 sits on top of main now.

The one conflict is the same as before with the sides swapped: both additions
land at the same point in _prepare_pr_review and neither knows about the other.
Finding state is appended before the outputs are pushed, so a sink receives the
markdown the reader sees rather than one missing the lifecycle state - which is
the only reason to want the sink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yefuyou
yefuyou force-pushed the feature/review-finding-lifecycle branch from e97e318 to 14889f5 Compare September 3, 2026 23:45
Comment thread tests/unittest/test_gitea_provider.py Fixed
Comment thread tests/unittest/test_pr_reviewer_finding_state.py Fixed
@yefuyou

yefuyou commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

@IsmaelMartinez Thanks again for the detailed review.

I’ve now rebased #2722 onto the latest main used for this update and worked through the lifecycle blockers you called out.

The updated branch now covers:

Validation after the final rebase:

  • focused lifecycle/provider tests: 532 passed
  • full unit suite: 3370 passed, 1 skipped, 1 xfailed, 1 failed
  • the single failure is the known unrelated Grok/LiteLLM reasoning-parameter test and was reproduced on the baseline
  • git diff --check: passed

A final test-only CodeQL cleanup then ran 261 passed, and both GitHub Build-and-test and CodeQL are green on the current head:

8374aea7ae932a71013f2460632db15e070bde9f

I’d appreciate another look when you have time. Thanks again for pushing on the trust boundary and failure semantics in particular.

@IsmaelMartinez

Copy link
Copy Markdown
Collaborator

Thanks for turning all of that round, and for the trust-boundary work in particular. One thing left before I can approve.

The fail-closed path is wider than it needs to be. Only GitHub, GitLab and Azure DevOps override supports_review_finding_state(), so Gitea and Bitbucket hit the base return False and land in the unverified-identity else in publish_review. Ran it on 8374aea7:

GiteaProvider      supports_state=False  ->  standalone banner + a new comment each run
BitbucketProvider  supports_state=False  ->  standalone banner + a new comment each run

Both override publish_persistent_comment and update a single review comment on main today, so this is a visible regression for them. The same gate also stops finding state ever being written for those providers, so the banner is defending state that does not exist.

Could that else fall back to plain publish_persistent_comment when the feature is inactive for the provider, keeping fail-closed for the case where state is genuinely in play?

Separately, tests/unittest/test_gitea_provider.py now conflicts with main.

@yefuyou
yefuyou force-pushed the feature/review-finding-lifecycle branch from 8374aea to c7467e8 Compare September 4, 2026 14:18
@yefuyou

yefuyou commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for turning all of that round, and for the trust-boundary work in particular. One thing left before I can approve.

The fail-closed path is wider than it needs to be. Only GitHub, GitLab and Azure DevOps override supports_review_finding_state(), so Gitea and Bitbucket hit the base return False and land in the unverified-identity else in publish_review. Ran it on 8374aea7:

GiteaProvider      supports_state=False  ->  standalone banner + a new comment each run
BitbucketProvider  supports_state=False  ->  standalone banner + a new comment each run

Both override publish_persistent_comment and update a single review comment on main today, so this is a visible regression for them. The same gate also stops finding state ever being written for those providers, so the banner is defending state that does not exist.

Could that else fall back to plain publish_persistent_comment when the feature is inactive for the provider, keeping fail-closed for the case where state is genuinely in play?

Separately, tests/unittest/test_gitea_provider.py now conflicts with main.

Thanks, good catch. I narrowed the fail-closed path so providers without lifecycle-state support now keep their existing plain persistent-review behavior, while lifecycle-capable providers still fail closed when authorship cannot be verified.

I also rebased onto current main and resolved the Gitea test conflict. The fix is on c7467e8, and both Build-and-test and CodeQL are green.

Thanks again for the careful review.

@qodo-code-review

qodo-code-review Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Code Review by Qodo

Grey Divider

Sorry, something went wrong

We weren't able to complete the code review on our side. Please try again manually by commenting /agentic_review on this PR.

Grey Divider

Qodo Logo

@yefuyou

yefuyou commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Quick follow-up: I ran an additional red-team pass after the last update and found a same-HEAD lifecycle edge case. An ACTIVE finding could be incorrectly resolved if a complete rerun on the same commit nondeterministically omitted it.

I tightened resolution so it now also requires a known changed HEAD; same-HEAD and missing-HEAD reruns preserve ACTIVE findings.

I then ran a real GitHub + model lifecycle smoke test. The same canonical review comment stayed in place through ACTIVE → ACTIVE → RESOLVED → RESOLVED, with no duplicate authoritative comments or false same-HEAD resolution.

The fix is on cc60818, and Build-and-test and CodeQL are green.

@IsmaelMartinez IsmaelMartinez left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, that is the narrowing I was after, and the same-HEAD case was a good one to catch on your own.

Approving. Verified per provider on cc608182: Gitea, Bitbucket and Bitbucket Server all report state not in play and route to plain publish_persistent_comment, so their single-comment behaviour is unchanged. GitHub, GitLab and Azure keep the fail-closed path. Full suite 3598 passed on the merged tree.

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.

Preserve resolved findings across review re-runs instead of overwriting them

3 participants