#3431·pr-agent

Azure persistent_finding_state never resolves findings because the head SHA is empty

Author: TLA020Created Sep 16, 2026Updated Sep 18, 2026

Git provider

Azure DevOps

System info

  • PR-Agent Docker image: pragent/pr-agent:0.45.0
  • Invocation: CLI from an Azure DevOps build-validation pipeline
  • Reproduced against current main at 8b09d86f0f50b586fb797cf7ab7ad1958fe34f37
  • Relevant settings:
toml
[pr_reviewer]
persistent_comment = true
persistent_finding_state = true
inline_key_issues = true

[azure_devops_server]
agent_identity = "<stable Azure identity UUID>"

Bug details

persistent_finding_state cannot resolve findings across full Azure DevOps review runs because the current PR head SHA is always recorded as an empty string.

In a real Azure DevOps PR, the persistent review marker after multiple pushes contains:

json
{
  "last_run": {
    "complete": true,
    "excluded_files": [],
    "head_sha": "",
    "kind": "full",
    "run_id": "https://dev.azure.com/.../commit/<actual-head-sha>"
  }
}

The visible run_id advances to the latest commit, but head_sha remains empty. Previously reported findings therefore remain ACTIVE. If the model restates the same defect with different wording, its body-derived finding ID also changes, so Azure receives another inline thread even when the earlier thread was answered and marked fixed, closed, or byDesign.

Root cause

PRReviewer._review_head_sha() only reads git_provider.last_commit_id:

python
def _review_head_sha(self) -> str:
    last_commit = getattr(self.git_provider, "last_commit_id", None)
    if isinstance(last_commit, str):
        return last_commit
    for attribute in ("sha", "id"):
        value = getattr(last_commit, attribute, None)
        if isinstance(value, str):
            return value
    return ""

AzureDevopsProvider never populates last_commit_id. The Azure PR object already exposes the head as:

python
provider.pr.last_merge_commit.commit_id

The reconciliation guard requires both the previous and current head SHA to be non-empty and different. With Azure's empty value, resolution_allowed can never become true.

Minimal reproduction

Run against current main:

python
from types import SimpleNamespace

from pr_agent.git_providers.azuredevops_provider import AzureDevopsProvider
from pr_agent.tools.pr_reviewer import PRReviewer

provider = AzureDevopsProvider.__new__(AzureDevopsProvider)
provider.pr = SimpleNamespace(
    last_merge_commit=SimpleNamespace(commit_id="head-2")
)

reviewer = PRReviewer.__new__(PRReviewer)
reviewer.git_provider = provider

assert provider.pr.last_merge_commit.commit_id == "head-2"
assert reviewer._review_head_sha() == "head-2"  # actual: ""

The downstream effect can be reproduced directly:

python
from pr_agent.algo.review_finding_state import reconcile_review_findings

finding = {
    "body": "Old finding",
    "path": "app.py",
    "line_start": 1,
    "line_end": 1,
}
previous = reconcile_review_findings(
    None,
    [finding],
    allow_resolution=True,
    head_sha="head-1",
).state

result = reconcile_review_findings(
    previous,
    [],
    allow_resolution=True,
    head_sha="",
)

assert result.resolved_ids  # actual: ()
assert result.state["findings"][0]["state"] == "RESOLVED"  # actual: ACTIVE

Expected behavior

  • A full Azure review records the actual source/head commit SHA.
  • On a later full review of a different head, absent findings can transition from ACTIVE to RESOLVED.
  • The regression test should use an Azure provider-shaped object rather than a generic mock that manually supplies last_commit_id.

Possible fix

Populate a stable head-SHA contract for Azure, for example by assigning the source commit ID during set_pr():

python
self.last_commit_id = self.pr.last_merge_commit.commit_id

Alternatively, introduce a provider method such as get_pr_head_sha() and have the review-state code call that instead of depending on the GitHub-shaped last_commit_id attribute.