Skip to content

fix(azure-devops): implement incremental review (-i) support (#2379)#2381

Open
agonzalesipcoop-cmyk wants to merge 7 commits intoThe-PR-Agent:mainfrom
agonzalesipcoop-cmyk:fix/azure-devops-incremental-review-2379
Open

fix(azure-devops): implement incremental review (-i) support (#2379)#2381
agonzalesipcoop-cmyk wants to merge 7 commits intoThe-PR-Agent:mainfrom
agonzalesipcoop-cmyk:fix/azure-devops-incremental-review-2379

Conversation

@agonzalesipcoop-cmyk
Copy link
Copy Markdown

Summary

Closes #2379.

pr-agent review -i against an Azure DevOps PR crashed with object of type 'NoneType' has no len() because AzureDevopsProvider inherited the no-op GitProvider.get_incremental_commits stub, leaving incremental.commits_range as None. The hasattr guard in _can_run_incremental_review was satisfied by the inherited stub and did not protect the len(...) call.

This PR fixes the crash and ships full incremental review support for Azure DevOps.

Bug fix — pr_agent/tools/pr_reviewer.py

  • Add commits_range is None guard in _can_run_incremental_review so any provider without a real incremental implementation degrades gracefully (logs and falls back to full review).
  • Defensive previous_review.html_url read in the no-new-files skip branch.

Feature — pr_agent/git_providers/azuredevops_provider.py

  • Implement get_incremental_commits, _get_incremental_commits, _get_commit_range, get_previous_review mirroring GitHubProvider.
  • _AzureCommitAdapter exposes the GitHub-shape (.sha, .commit.author.date, .commit.message, .parents) so the shared IncrementalPR and pr_reviewer code paths need no changes.
  • Reverse Azure's newest-first commit order to oldest-first to match GitHub iteration semantics.
  • Normalize tz-aware UTC datetimes (Azure SDK) to naive UTC, matching PyGithub and pr_reviewer's naive datetime.now() comparison.
  • Bridge Azure Comment to GitHub-shape (html_url via existing get_comment_url, created_at from published_date).
  • Filter get_diff_files to unreviewed_files_set and rebuild patches against last_seen_commit_sha when incremental — so -i actually saves tokens, not just file count.
  • get_files override returns the unreviewed set when incremental.
  • Skip merge commits (multiple parents) when collecting changes.
  • Early-return in get_diff_files when pr.last_merge_commit is None instead of crashing on head_sha.commit_id (issue point Fix encoding error on special_tokens #7).

Approach note

Used the adapter at the seam approach (option B from the issue) rather than renaming shared IncrementalPR attributes — keeps pr_reviewer.py and GitHubProvider untouched.

Test plan

  • tests/unittest/test_azure_devops_incremental.py — 9 new cases:
    • _to_naive_utc (tz-aware → naive, naive passthrough, None passthrough)
    • _AzureCommitAdapter shape + missing-author handling
    • _get_incremental_commits with no previous review (disables incremental)
    • Full incremental path: populates commits_range, first_new_commit, last_seen_commit, unreviewed_files_set, filters gitObjectType == "tree", sets html_url
    • Skips merge commits (parents > 1)
    • _can_run_incremental_review returns False (not crash) when commits_range is None
  • Existing tests/unittest/test_azure_devops_parsing.py and tests/unittest/test_azure_devops_comment.py still pass (no regressions)
  • End-to-end run on a real Azure DevOps PR: full review posts a ## PR Reviewer Guide comment; subsequent review -i no longer crashes, finds the previous review, computes the unreviewed set, and either runs an incremental review or skips cleanly with a working [previous PR Review](...) link

🤖 Generated with Claude Code

Closes The-PR-Agent#2379.

`pr-agent review -i` against an Azure DevOps PR crashed with
`object of type 'NoneType' has no len()` because AzureDevopsProvider
inherited the no-op `GitProvider.get_incremental_commits` stub, leaving
`incremental.commits_range` as None. The hasattr guard in
`_can_run_incremental_review` was satisfied by the inherited stub.

Bug:
- Add `commits_range is None` guard in `_can_run_incremental_review`
  so any provider without real incremental support degrades gracefully.
- Defensive `previous_review.html_url` read in the no-new-files branch.

Feature (AzureDevopsProvider):
- Implement `get_incremental_commits`, `_get_incremental_commits`,
  `_get_commit_range`, `get_previous_review` mirroring GitHubProvider.
- `_AzureCommitAdapter` exposes the GitHub-shape (.sha, .commit.author.date,
  .commit.message, .parents) so shared code in pr_reviewer/IncrementalPR
  needs no changes.
- Reverse Azure's newest-first commit order to oldest-first to match
  GitHub iteration.
- Normalize tz-aware UTC datetimes (Azure SDK) to naive UTC, matching
  PyGithub semantics and `pr_reviewer`'s naive `datetime.now()` compare.
- Bridge Azure Comment to GitHub-shape attributes (`html_url` via
  existing `get_comment_url`, `created_at` from `published_date`).
- Filter `get_diff_files` to `unreviewed_files_set` and rebuild patches
  against `last_seen_commit_sha` when incremental, so token savings
  actually materialize.
- `get_files` override returns the unreviewed set when incremental.
- Skip merge commits (multiple parents) when collecting changes.
- Early-return in `get_diff_files` when `pr.last_merge_commit is None`
  instead of crashing on `head_sha.commit_id`.

Tests: tests/unittest/test_azure_devops_incremental.py (9 cases) covers
tz normalization, adapter shape, no-previous-review fallback, full
incremental path, merge-commit skip, and the commits_range None guard.
@github-actions github-actions Bot added the bug label May 8, 2026
@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Implement incremental review support for Azure DevOps provider

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Fix crash in incremental review for Azure DevOps by adding commits_range is None guard
• Implement full incremental review support for Azure DevOps provider
  - Add get_incremental_commits, _get_commit_range, get_previous_review methods
  - Create _AzureCommitAdapter to expose GitHub-compatible commit shape
  - Normalize tz-aware UTC datetimes to naive UTC for consistency
• Filter diff files to unreviewed set and rebuild patches against last seen commit
• Add defensive null checks for previous_review.html_url access
Diagram
flowchart LR
  A["Azure DevOps PR"] -->|get_incremental_commits| B["Fetch commits & previous review"]
  B -->|_AzureCommitAdapter| C["GitHub-compatible commit shape"]
  C -->|_get_commit_range| D["Filter commits after last review"]
  D -->|get_changes| E["Collect unreviewed files"]
  E -->|get_diff_files| F["Build patches for new files only"]
  F -->|pr_reviewer| G["Run incremental review"]
  H["commits_range is None guard"] -->|fallback| I["Full review"]
Loading

Grey Divider

File Changes

1. pr_agent/git_providers/azuredevops_provider.py ✨ Enhancement +162/-2

Implement incremental review with commit adapter

• Add _to_naive_utc helper to normalize tz-aware UTC datetimes to naive UTC
• Implement _AzureCommitAdapter class to expose GitHub-compatible commit shape (.sha,
 .commit.author.date, .commit.message, .parents)
• Implement get_incremental_commits, _get_incremental_commits, _get_commit_range,
 get_previous_review methods for incremental review support
• Reverse Azure's newest-first commit order to oldest-first to match GitHub semantics
• Filter get_diff_files to unreviewed files and rebuild patches against last_seen_commit_sha
 when incremental
• Override get_files to return unreviewed set when incremental is active
• Add early-return in get_diff_files when pr.last_merge_commit is None to prevent crashes
• Skip merge commits (multiple parents) when collecting changes

pr_agent/git_providers/azuredevops_provider.py


2. pr_agent/tools/pr_reviewer.py 🐞 Bug fix +8/-2

Add guards for incremental review initialization

• Add commits_range is None guard in _can_run_incremental_review to gracefully degrade when
 provider lacks real incremental support
• Add defensive null check for previous_review object before accessing html_url attribute
• Use getattr with fallback to safely read html_url from previous review

pr_agent/tools/pr_reviewer.py


3. tests/unittest/test_azure_devops_incremental.py 🧪 Tests +169/-0

Add comprehensive incremental review unit tests

• Add 9 new test cases covering incremental review functionality
• Test _to_naive_utc with tz-aware, naive, and None inputs
• Test _AzureCommitAdapter shape and missing author handling
• Test _get_incremental_commits with no previous review (disables incremental)
• Test full incremental path: commits_range, first_new_commit, last_seen_commit,
 unreviewed_files_set population
• Test filtering of tree objects (directories) from unreviewed files
• Test merge commit skipping (parents > 1)
• Test _can_run_incremental_review returns False when commits_range is None

tests/unittest/test_azure_devops_incremental.py


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects Bot commented May 8, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (1) 📎 Requirement gaps (0)

Context used

Grey Divider


Action required

1. Stale diff cache reused 🐞 Bug ☼ Reliability ⭐ New
Description
AzureDevopsProvider.get_diff_files() now returns cached self.diff_files whenever it’s not
None, but neither set_pr() nor get_incremental_commits() resets
diff_files/pr_commits/previous_review; if the provider instance is reused, incremental and
full reviews can return stale diffs and wrong review scope. This can cause incremental reviews to
unintentionally include the full PR diff (or vice versa), defeating the feature and potentially
reviewing incorrect content.
Code

pr_agent/git_providers/azuredevops_provider.py[R363-365]

+            if self.diff_files is not None:
                return self.diff_files
Evidence
The provider caches diff computation via self.diff_files and returns it whenever not None, but
the new incremental state (pr_commits, previous_review, unreviewed_files_map) is not reset on
set_pr() or get_incremental_commits(). The codebase also attempts to reuse provider instances
via a context cache, making stale state a realistic scenario in long-running executions.

pr_agent/git_providers/azuredevops_provider.py[186-198]
pr_agent/git_providers/azuredevops_provider.py[199-210]
pr_agent/git_providers/azuredevops_provider.py[360-365]
pr_agent/git_providers/init.py[40-65]

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

### Issue description
`AzureDevopsProvider` caches diff/commit-derived state (`diff_files`, `pr_commits`, `previous_review`, and `unreviewed_files_map`) but does not reset it when (a) a new PR is set via `set_pr()` or (b) incremental mode is enabled via `get_incremental_commits()`. In reused provider instances, `get_diff_files()` can return stale cached results and ignore the current incremental/full mode.

### Issue Context
The codebase has a context-based provider reuse mechanism; even if reuse is not always active, the provider should be safe under reuse to avoid returning wrong diffs.

### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[186-198]
- pr_agent/git_providers/azuredevops_provider.py[191-210]
- pr_agent/git_providers/azuredevops_provider.py[360-365]

### Suggested fix
- In `set_pr()`: clear PR-specific caches, e.g. `self.diff_files = None`, `self.pr_commits = None`, `self.previous_review = None`, and `self.unreviewed_files_map = {}`.
- In `get_incremental_commits()`: also clear `self.diff_files` (and potentially `self.pr_commits` if you need a fresh commit list) before computing incremental state, so incremental diff filtering/rebuilding is based on current data.

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


2. Missing dates skip review ✓ Resolved 🐞 Bug ≡ Correctness
Description
AzureDevopsProvider._get_commit_range can return an empty commits_range when the previous review
timestamp is missing or commit author dates are None, without disabling incremental mode. PRReviewer
then fails the threshold check and exits early, potentially skipping a requested incremental review
instead of falling back to a full review.
Code

pr_agent/git_providers/azuredevops_provider.py[R258-273]

+    def _get_commit_range(self):
+        last_review_time = _to_naive_utc(getattr(self.previous_review, "created_at", None))
+        if last_review_time is None or not self.pr_commits:
+            return []
+        first_new_commit_index = None
+        for index in range(len(self.pr_commits) - 1, -1, -1):
+            cdate = self.pr_commits[index].commit.author.date
+            if cdate is None:
+                continue
+            if cdate > last_review_time:
+                self.incremental.first_new_commit = self.pr_commits[index]
+                first_new_commit_index = index
+            else:
+                self.incremental.last_seen_commit = self.pr_commits[index]
+                break
+        return self.pr_commits[first_new_commit_index:] if first_new_commit_index is not None else []
Evidence
The adapter explicitly allows missing author/date (sets author_date to None), and _get_commit_range
both (a) returns [] immediately when last_review_time is None and (b) skips commits with cdate is
None, which can leave first_new_commit_index unset and produce an empty list.
_get_incremental_commits assigns that empty list to incremental.commits_range and does not disable
incremental; PRReviewer’s gate only disables incremental when commits_range is None, so an empty
list proceeds to threshold evaluation and can cause the run to return early while incremental is
still enabled.

pr_agent/git_providers/azuredevops_provider.py[44-51]
pr_agent/git_providers/azuredevops_provider.py[258-273]
pr_agent/git_providers/azuredevops_provider.py[211-218]
pr_agent/tools/pr_reviewer.py[126-130]
pr_agent/tools/pr_reviewer.py[343-373]

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

## Issue description
Azure incremental mode can remain enabled even when commit-range computation is not possible (e.g., previous review has no timestamp, or commits have missing author dates). This can lead PRReviewer to exit early (threshold failure) and skip a requested review instead of degrading to a full review.
### Issue Context
- `_AzureCommitInner` allows `author.date` to be `None`.
- `_get_commit_range` returns `[]` when `last_review_time is None` and also skips commits with `cdate is None`.
- Only `commits_range is None` triggers the PRReviewer fallback-to-full path.
### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[258-273]
### Suggested change
- Treat missing `last_review_time` as “incremental unsupported/uninitialized”:
- log a warning/info
- set `self.incremental.is_incremental = False` and/or leave `self.incremental.commits_range = None` (so PRReviewer falls back to full)
- If `cdate is None` is encountered in the scan, consider marking the range computation as unreliable and similarly disabling incremental (or at least avoid returning an empty range due to skipped metadata).
- Ensure the “no new commits” case (real empty range with reliable timestamps) still behaves as intended (skip incremental rather than force full).

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


3. _can_run_incremental_review returns True ✓ Resolved 📎 Requirement gap ☼ Reliability
Description
When incremental.commits_range is None, _can_run_incremental_review disables incremental mode
but still returns True, contradicting the compliance requirement to treat this case as
unsupported/skipped incremental mode. This can cause inconsistent control flow (and currently
contradicts the new unit test that expects False).
Code

pr_agent/tools/pr_reviewer.py[R340-346]

+        if self.incremental.commits_range is None:
+            get_logger().info(
+                f"Incremental review not initialized for {get_settings().config.git_provider}; "
+                f"falling back to full review."
+            )
+            self.incremental.is_incremental = False
+            return True
Evidence
PR Compliance ID 6 requires _can_run_incremental_review to return False when
incremental.commits_range is None to ensure unsupported incremental mode is detected and skipped
safely. The new code logs and flips is_incremental to False but returns True, and the newly
added test explicitly expects False for this scenario.

Incremental review (-i) must not crash when provider lacks incremental support
pr_agent/tools/pr_reviewer.py[340-346]
tests/unittest/test_azure_devops_incremental.py[155-166]

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

## Issue description
`_can_run_incremental_review()` currently returns `True` when `incremental.commits_range` is `None`, even though it disables incremental mode. The compliance requirement for unsupported/uninitialized incremental mode expects this to be treated as a `False` gating result, while still allowing the command to fall back to a full review without crashing.
## Issue Context
- In `PRReviewer.run()`, the current gating condition is `if self.incremental.is_incremental and not self._can_run_incremental_review(): return None`. If `_can_run_incremental_review()` is updated to return `False` for the `commits_range is None` case, `run()` must be adjusted so that when `_can_run_incremental_review()` *disables* incremental mode, execution continues with a full review rather than returning early.
- The newly added unit test expects `_can_run_incremental_review()` to return `False` when `commits_range` is `None`.
## Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[340-346]
- pr_agent/tools/pr_reviewer.py[120-128]
- tests/unittest/test_azure_devops_incremental.py[155-166]

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


View more (3)
4. Old review comment chosen ✓ Resolved 🐞 Bug ≡ Correctness
Description
AzureDevopsProvider.get_previous_review() returns the first matching review comment from
get_issue_comments(), which can select an older PR-Agent review when multiple exist. This can make
incremental review compute the commit range from a stale review and re-review already-reviewed
changes.
Code

pr_agent/git_providers/azuredevops_provider.py[R267-281]

+    def get_previous_review(self, *, full: bool, incremental: bool):
+        if not (full or incremental):
+            raise ValueError("At least one of full or incremental must be True")
+        prefixes = []
+        if full:
+            prefixes.append(PRReviewHeader.REGULAR.value)
+        if incremental:
+            prefixes.append(PRReviewHeader.INCREMENTAL.value)
+        for comment in self.get_issue_comments():
+            body = getattr(comment, "body", None)
+            if body and any(body.startswith(p) for p in prefixes):
+                comment.html_url = self.get_comment_url(comment)
+                comment.created_at = _to_naive_utc(getattr(comment, "published_date", None))
+                return comment
+        return None
Evidence
Unlike GithubProvider (which scans comments from newest to oldest), AzureDevopsProvider scans
forward and returns the first match. Combined with AzureDevopsProvider.get_issue_comments()
reversing thread order before building the comment list, this does not reliably select the latest
PR-Agent review comment.

pr_agent/git_providers/azuredevops_provider.py[267-281]
pr_agent/git_providers/azuredevops_provider.py[673-683]
pr_agent/git_providers/github_provider.py[182-195]

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

## Issue description
`AzureDevopsProvider.get_previous_review()` returns the first matching PR-Agent review comment instead of the most recent one. When multiple PR-Agent reviews exist on the PR, incremental mode may use an outdated review timestamp and compute an incorrect commit/file range.
### Issue Context
`GithubProvider.get_previous_review()` iterates from the end of the comment list to find the most recent match. Azure should mirror that behavior (either reverse-iterate the returned list or explicitly sort by `published_date`/`created_at`).
### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[267-281]
- pr_agent/git_providers/azuredevops_provider.py[673-683]
### Proposed change
Change `get_previous_review()` to select the newest matching review comment, e.g.:
- Iterate `for comment in reversed(self.get_issue_comments()): ...`, OR
- Filter matching comments then pick `max(..., key=lambda c: _to_naive_utc(getattr(c, 'published_date', None)) or datetime.min)`.
Also consider adding a unit test covering multiple matching review comments to ensure the newest is selected.

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


5. Unsafe .additional_properties.get() access ✓ Resolved 📘 Rule violation ☼ Reliability
Description
The incremental change parsing assumes change.additional_properties is a dict and calls
.get(...) on it, which can raise AttributeError if the SDK returns additional_properties=None.
This violates the requirement to defensively handle variable external payload shapes and can crash
incremental review on Azure DevOps.
Code

pr_agent/git_providers/azuredevops_provider.py[R232-236]

+                try:
+                    item = change["item"]
+                except (KeyError, TypeError):
+                    item = getattr(change, "additional_properties", {}).get("item", {}) or {}
+                if not isinstance(item, dict) or item.get("gitObjectType") == "tree":
Evidence
PR Compliance ID 30 requires defensive access patterns for optional/variable external structures. In
the added Azure incremental code, the fallback path calls .get on `getattr(change,
"additional_properties", {}), which still returns None when the attribute exists but is None`,
leading to a crash.

pr_agent/git_providers/azuredevops_provider.py[232-236]
Best Practice: Learned patterns

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 Azure incremental change parsing can crash when `change.additional_properties` exists but is `None`, because the code calls `.get(...)` on it.
## Issue Context
Azure DevOps SDK response shapes can vary; `additional_properties` is not guaranteed to be a dict.
## Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[232-236]

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


6. Fallback exits review ✓ Resolved 🐞 Bug ≡ Correctness
Description
PRReviewer._can_run_incremental_review() returns False when incremental.commits_range is
None, but PRReviewer.run() treats that as a hard stop and returns early, so review -i can
silently produce no review instead of falling back. This contradicts the log message “falling back
to full review.”
Code

pr_agent/tools/pr_reviewer.py[R340-345]

+        if self.incremental.commits_range is None:
+            get_logger().info(
+                f"Incremental review not initialized for {get_settings().config.git_provider}; "
+                f"falling back to full review."
+            )
+            return False
Evidence
run() exits immediately when incremental is enabled and _can_run_incremental_review() returns
False. The new commits_range is None guard returns False while claiming a full-review
fallback, but no code path actually disables incremental and continues with a full review.

pr_agent/tools/pr_reviewer.py[120-128]
pr_agent/tools/pr_reviewer.py[328-346]

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

## Issue description
When `-i` is requested but the provider didn’t initialize `incremental.commits_range` (left as `None`), `_can_run_incremental_review()` returns `False`, and `run()` returns early. This prevents any review from running, despite the log claiming it will fall back to a full review.
### Issue Context
`run()` currently interprets `_can_run_incremental_review() == False` as “stop the command”. For the specific “commits_range is None” case, the intended behavior is to disable incremental mode and proceed with a full review.
### Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[120-128]
- pr_agent/tools/pr_reviewer.py[328-346]
### Suggested change
In the `commits_range is None` branch, set `self.incremental.is_incremental = False` and return `True` (or refactor `run()` to disable incremental and continue) so a full review proceeds.

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



Remediation recommended

7. Missing last-seen commit 🐞 Bug ≡ Correctness ⭐ New
Description
AzureDevopsProvider._get_commit_range() can return a non-empty commits_range without setting
incremental.last_seen_commit (e.g., if all commit author dates are greater than the previous
review timestamp), leaving last_seen_commit_sha as None. get_diff_files() then disables
incremental diff rebuilding/filtering (because it requires last_seen_commit_sha), so -i can
silently run with full diffs despite remaining in incremental mode.
Code

pr_agent/git_providers/azuredevops_provider.py[R277-287]

+        for index in range(len(self.pr_commits) - 1, -1, -1):
+            cdate = self.pr_commits[index].commit.author.date
+            if cdate is None:
+                continue
+            saw_reliable_date = True
+            if cdate > last_review_time:
+                self.incremental.first_new_commit = self.pr_commits[index]
+                first_new_commit_index = index
+            else:
+                self.incremental.last_seen_commit = self.pr_commits[index]
+                break
Evidence
The commit-range loop only sets last_seen_commit when it finds a commit with `cdate <=
last_review_time`; if it never finds such a commit, it returns a slice of new commits while
last_seen_commit stays None. Since IncrementalPR.last_seen_commit_sha becomes None in that
case, Azure’s get_diff_files() won’t activate incremental diff rebuilding/filtering (it explicitly
requires last_seen_commit_sha).

pr_agent/git_providers/azuredevops_provider.py[266-296]
pr_agent/git_providers/git_provider.py[477-490]
pr_agent/git_providers/azuredevops_provider.py[440-448]

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

### Issue description
`_get_commit_range()` can produce a commits range but not set `incremental.last_seen_commit`, which prevents incremental patch rebuilding/filtering and causes `-i` to degrade into full diffs while still treating the run as incremental.

### Issue Context
This can happen when no commit in the PR has `author.date <= previous_review.created_at` (for example due to clock skew/future-dated commits). Azure’s incremental diff logic requires `incremental.last_seen_commit_sha`.

### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[266-296]
- pr_agent/git_providers/azuredevops_provider.py[440-448]

### Suggested fix
After the loop in `_get_commit_range()`, add a guard:
- If `first_new_commit_index is not None` and `self.incremental.last_seen_commit is None`, explicitly fall back to full review (`self.incremental.is_incremental = False; return None`) OR derive a base SHA reliably (e.g., from `self.pr.last_merge_target_commit.commit_id`) and store it in a way that enables incremental patch rebuilding.
This keeps incremental mode internally consistent and prevents silent full-diff behavior under `-i`.

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


8. _AzureCommitAdapter._raw unused field ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
_AzureCommitAdapter stores the raw commit object in self._raw but that attribute is never used,
adding dead/unused code to the provider. This increases maintenance surface without functional
benefit.
Code

pr_agent/git_providers/azuredevops_provider.py[R55-60]

+    def __init__(self, raw):
+        self._raw = raw
+        self.sha = raw.commit_id
+        self.commit_id = raw.commit_id
+        self.commit = _AzureCommitInner(raw)
+        self.parents = list(getattr(raw, "parents", None) or [])
Evidence
PR Compliance ID 2 disallows dead/unused code; the PR adds self._raw = raw to
_AzureCommitAdapter without any subsequent usage in the adapter implementation.

Rule 2: No Dead or Commented-Out Code
pr_agent/git_providers/azuredevops_provider.py[55-60]

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

## Issue description
`_AzureCommitAdapter` sets `self._raw = raw`, but the attribute is not used anywhere in the adapter. This is dead/unused code and should be removed unless there is a concrete need for it.
## Issue Context
Keeping unused fields increases maintenance burden and can confuse future refactors.
## Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[55-60]

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


9. New tests use unittest ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The newly added test module is written using unittest.TestCase, which diverges from the existing
pytest-style tests in tests/unittest/. This reduces consistency and may not align with the
repository’s expected testing patterns.
Code

tests/unittest/test_azure_devops_incremental.py[R1-76]

+import datetime as _dt
+import unittest
+from unittest.mock import MagicMock, patch
+
+from pr_agent.git_providers import AzureDevopsProvider
+from pr_agent.git_providers.azuredevops_provider import (
+    _AzureCommitAdapter,
+    _to_naive_utc,
+)
+from pr_agent.git_providers.git_provider import IncrementalPR
+
+
+def _raw_commit(commit_id, comment, author_date, parents=None):
+    raw = MagicMock()
+    raw.commit_id = commit_id
+    raw.comment = comment
+    raw.author = MagicMock()
+    raw.author.date = author_date
+    raw.parents = parents or []
+    return raw
+
+
+def _comment(body, published_date):
+    c = MagicMock()
+    c.body = body
+    c.content = body
+    c.published_date = published_date
+    c.thread_id = 7
+    return c
+
+
+class TestNaiveUtc(unittest.TestCase):
+    def test_strips_tz_from_aware(self):
+        aware = _dt.datetime(2024, 1, 1, 12, 0, tzinfo=_dt.timezone.utc)
+        naive = _to_naive_utc(aware)
+        self.assertIsNone(naive.tzinfo)
+        self.assertEqual(naive, _dt.datetime(2024, 1, 1, 12, 0))
+
+    def test_passes_naive_through(self):
+        naive = _dt.datetime(2024, 1, 1, 12, 0)
+        self.assertEqual(_to_naive_utc(naive), naive)
+
+    def test_none_returns_none(self):
+        self.assertIsNone(_to_naive_utc(None))
+
+
+class TestAzureCommitAdapter(unittest.TestCase):
+    def test_exposes_github_shape(self):
+        date = _dt.datetime(2024, 1, 1, 12, 0, tzinfo=_dt.timezone.utc)
+        raw = _raw_commit("abc123", "fix bug", date, parents=["p1"])
+        adapter = _AzureCommitAdapter(raw)
+        self.assertEqual(adapter.sha, "abc123")
+        self.assertEqual(adapter.commit_id, "abc123")
+        self.assertEqual(adapter.commit.message, "fix bug")
+        self.assertIsNone(adapter.commit.author.date.tzinfo)
+        self.assertEqual(adapter.parents, ["p1"])
+
+    def test_handles_missing_author(self):
+        raw = MagicMock()
+        raw.commit_id = "x"
+        raw.comment = ""
+        raw.author = None
+        raw.parents = None
+        adapter = _AzureCommitAdapter(raw)
+        self.assertIsNone(adapter.commit.author.date)
+        self.assertEqual(adapter.parents, [])
+
+
+class TestGetIncrementalCommits(unittest.TestCase):
+    def _make_provider(self):
+        with patch.object(
+            AzureDevopsProvider, "_get_azure_devops_client",
+            return_value=(MagicMock(), MagicMock()),
+        ):
+            provider = AzureDevopsProvider()
+        provider.workspace_slug = "ws"
Evidence
PR Compliance ID 20 requires pytest coverage and alignment with existing testing patterns; this PR
adds a new unittest-style test suite (unittest import + unittest.TestCase classes).

AGENTS.md
tests/unittest/test_azure_devops_incremental.py[1-76]

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 new tests are written with `unittest.TestCase`, which is inconsistent with the existing pytest-style tests in `tests/unittest/`. Convert these tests to pytest-style functions/classes using plain `assert` and pytest fixtures/mocking where appropriate.
## Issue Context
The repository’s unit tests in `tests/unittest/` are primarily pytest-style, so keeping the new tests consistent improves readability and maintenance.
## Fix Focus Areas
- tests/unittest/test_azure_devops_incremental.py[1-169]

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


View more (9)
10. Merge commits ignored ✓ Resolved 🐞 Bug ≡ Correctness
Description
AzureDevopsProvider incremental change collection skips all commits with multiple parents, so
changes introduced via merge commits (e.g., conflict resolutions) are excluded from
unreviewed_files_map. If the incremental range contains only merge commits, PRReviewer will treat
the incremental run as having no new files and skip the review, missing those changes.
Code

pr_agent/git_providers/azuredevops_provider.py[R222-258]

+        for commit in self.incremental.commits_range:
+            if len(commit.parents) > 1:
+                get_logger().info(f"Skipping merge commit {commit.sha}")
+                continue
+            try:
+                changes_obj = self.azure_devops_client.get_changes(
+                    project=self.workspace_slug,
+                    repository_id=self.repo_slug,
+                    commit_id=commit.commit_id,
+                )
+            except Exception as e:
+                had_errors = True
+                get_logger().warning(f"Failed to fetch changes for {commit.commit_id}: {e}")
+                continue
+            for change in (getattr(changes_obj, "changes", None) or []):
+                try:
+                    item = change["item"]
+                except (KeyError, TypeError):
+                    additional = getattr(change, "additional_properties", None) or {}
+                    item = additional.get("item") or {}
+                if not isinstance(item, dict) or item.get("gitObjectType") == "tree":
+                    continue
+                path = item.get("path")
+                if path:
+                    candidate_paths.append(path)
+
+        if candidate_paths:
+            deduped = list(dict.fromkeys(candidate_paths))
+            filtered = filter_ignored(deduped, "azure")
+            for path in filtered:
+                if is_valid_file(path):
+                    self.unreviewed_files_map[path] = path
+        elif had_errors and self.incremental.commits_range:
+            get_logger().warning(
+                "Failed to fetch changes for incremental commits; falling back to full review."
+            )
+            self.incremental.is_incremental = False
Evidence
In AzureDevopsProvider._get_incremental_commits, merge commits are skipped entirely based on parent
count, and candidate_paths/unreviewed_files_map are populated only from non-skipped commits.
PRReviewer later skips incremental review when unreviewed_files_map is empty, so any changes that
exist only in merge commits won’t be reviewed in incremental mode.

pr_agent/git_providers/azuredevops_provider.py[220-258]
pr_agent/tools/pr_reviewer.py[145-157]

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

## Issue description
Incremental Azure DevOps logic skips all merge commits (`len(parents) > 1`) when collecting changed paths. If the only new commits since the last review are merge commits, `unreviewed_files_map` stays empty and PRReviewer will skip the incremental run, potentially missing conflict-resolution changes.
### Issue Context
- `_get_incremental_commits()` currently `continue`s for merge commits without collecting any paths.
- PRReviewer skips incremental review when `unreviewed_files_map` is empty.
### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[220-258]
- pr_agent/tools/pr_reviewer.py[145-157]
### Suggested fix approach
- Option A (safer correctness): for merge commits, still compute and include changed paths (e.g., via `get_changes(commit_id=...)`) instead of skipping entirely.
- Option B (explicit fallback): if all commits in `commits_range` are merge commits, disable incremental (`incremental.is_incremental = False`) so the run proceeds as a full review rather than being skipped.
- Add a unit test where `commits_range` contains only merge commits and assert the chosen behavior (either paths collected or incremental disabled).

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


11. Long unreviewed_files_map conditional 📘 Rule violation ⚙ Maintainability
Description
A newly added if statement exceeds the 120-character Ruff line-length convention, which can cause
style/lint failures in CI. This should be wrapped across multiple lines (or refactored into named
booleans) to match the repository formatting rules.
Code

pr_agent/tools/pr_reviewer.py[145]

+            if self.incremental.is_incremental and hasattr(self.git_provider, "unreviewed_files_map") and not self.git_provider.unreviewed_files_map:
Evidence
PR Compliance ID 14 requires Python changes to follow Ruff style, including a 120-character line
limit. The added conditional on unreviewed_files_map is a single long line that violates this
convention.

AGENTS.md
pr_agent/tools/pr_reviewer.py[145-145]

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 `if` condition is written on a single line that exceeds the repo's Ruff line-length convention (120 chars), risking lint failures.
## Issue Context
This is in the incremental-review early-exit path. The logic is fine, but it should be formatted to comply with Ruff formatting expectations.
## Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[145-145]

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


12. Misnamed unreviewed_files_set dict ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
unreviewed_files_set is introduced/used as a dict (path→path/patch), not a set, which is
misleading and breaks consistent naming conventions. This increases maintenance risk and can lead to
incorrect assumptions by future code.
Code

pr_agent/git_providers/azuredevops_provider.py[R82-84]

+        self.unreviewed_files_set = {}
+        self.pr_commits = None
+        self.previous_review = None
Evidence
PR Compliance ID 1 requires identifiers to follow established naming standards. The new attribute
name unreviewed_files_set implies a set, but the code initializes it as {} and later stores
patch values in it, demonstrating it is a dict/map rather than a set.

Rule 1: Consistent Naming Conventions
pr_agent/git_providers/azuredevops_provider.py[82-83]
pr_agent/git_providers/azuredevops_provider.py[505-506]

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

## Issue description
`unreviewed_files_set` is named like a set but is implemented as a dict and later used to store patches, which is misleading and violates naming conventions.
## Issue Context
The attribute is initialized as `{}` and later mutated to hold `patch` strings, so its semantics are “map” rather than “set”.
## Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[79-84]
- pr_agent/git_providers/azuredevops_provider.py[246-257]
- pr_agent/git_providers/azuredevops_provider.py[415-423]
- pr_agent/git_providers/azuredevops_provider.py[482-507]

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


13. Line exceeds 120 chars ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
New code introduces at least one line that likely exceeds the 120-character limit, increasing
readability and risking style/lint failures. This violates the formatting requirement to keep lines
within 120 characters.
Code

pr_agent/git_providers/azuredevops_provider.py[R290-292]

+        latest = max(matches, key=lambda c: _to_naive_utc(getattr(c, "published_date", None)) or _dt.datetime.min)
+        latest.html_url = self.get_comment_url(latest)
+        latest.created_at = _to_naive_utc(getattr(latest, "published_date", None))
Evidence
PR Compliance ID 16 requires keeping a 120-character line length. The newly added `latest =
max(matches, key=lambda c: ...)` line is a single long expression that exceeds typical 120-char
constraints and should be wrapped.

AGENTS.md
pr_agent/git_providers/azuredevops_provider.py[290-292]

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 statement is too long for the repository’s 120-character line-length rule.
## Issue Context
Long, single-line expressions reduce readability and may violate Ruff/formatting checks.
## Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[290-292]

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


14. Duplicate original fetch ✓ Resolved 🐞 Bug ➹ Performance
Description
In AzureDevopsProvider.get_diff_files, when incremental mode is active the code fetches the original
file content at the PR base SHA and then re-fetches and overwrites it with the last_seen_commit SHA,
causing redundant Azure API calls per file in incremental runs.
Code

pr_agent/git_providers/azuredevops_provider.py[R482-501]

+                if incremental_active:
+                    inc_version = GitVersionDescriptor(
+                        version=self.incremental.last_seen_commit_sha, version_type="commit"
+                    )
+                    try:
+                        inc_original = self.azure_devops_client.get_item(
+                            repository_id=self.repo_slug,
+                            path=file,
+                            project=self.workspace_slug,
+                            version_descriptor=inc_version,
+                            download=False,
+                            include_content=True,
+                        )
+                        original_file_content_str = inc_original.content or ""
+                    except Exception as error:
+                        get_logger().warning(
+                            f"Failed to retrieve original of {file} at {self.incremental.last_seen_commit_sha}: {error}"
+                        )
+                        original_file_content_str = ""
+
Evidence
The function first retrieves original_file_content_str at base_sha for non-added/non-renamed files,
then (in the new incremental branch) performs a second get_item call using last_seen_commit_sha and
overwrites original_file_content_str, making the base fetch wasted whenever incremental_active is
true.

pr_agent/git_providers/azuredevops_provider.py[462-501]

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

## Issue description
When `incremental_active` is true in `AzureDevopsProvider.get_diff_files`, the method fetches `original_file_content_str` twice: first at `base_sha.commit_id`, then again at `self.incremental.last_seen_commit_sha`, overwriting the first result.
### Issue Context
This adds avoidable network/API overhead during incremental reviews.
### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[462-501]
### Implementation sketch
- Compute `incremental_active` before fetching the base version.
- If `incremental_active` is true, skip the `base_sha` original fetch entirely and only fetch `inc_original` (or use `""` for added/renamed files).
- Otherwise (non-incremental), keep the current base/head behavior.

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


15. Empty diff cache bypass ✓ Resolved 🐞 Bug ☼ Reliability
Description
AzureDevopsProvider.get_diff_files assigns self.diff_files = [] on the early-return path, but the
cache guard is if self.diff_files: so an empty cached result is never reused and the method
repeats work and logging on subsequent calls.
Code

pr_agent/git_providers/azuredevops_provider.py[R338-347]

      if self.diff_files:
          return self.diff_files
+            if self.pr.last_merge_commit is None or self.pr.last_merge_target_commit is None:
+                get_logger().info(
+                    f"PR {self.pr_num} has no last_merge_commit/last_merge_target_commit; "
+                    f"cannot compute diff files."
+                )
+                self.diff_files = []
+                return []
Evidence
The caching condition checks truthiness (if self.diff_files:), but the new early-return path sets
self.diff_files to an empty list. In Python, [] is falsy, so later calls will not return the
cached empty list and will re-run the function body again.

pr_agent/git_providers/azuredevops_provider.py[335-347]

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

## Issue description
`get_diff_files()` attempts to cache results in `self.diff_files`, but it uses `if self.diff_files:` which fails to treat an empty list as a cached value. The new early-return path explicitly sets `self.diff_files = []`, which will never be returned by the guard.
### Issue Context
This can cause repeated execution and repeated log lines on every call when the correct cached result is `[]`.
### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[335-347]
### Implementation sketch
- Initialize `self.diff_files` to `None` (already done).
- Change the guard from `if self.diff_files:` to `if self.diff_files is not None:` so `[]` is treated as a valid cached result.
- Keep the rest of the logic unchanged.

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


16. Errors treated as no-files ✓ Resolved 🐞 Bug ☼ Reliability
Description
AzureDevopsProvider._get_incremental_commits continues on get_changes exceptions and only
populates unreviewed_files_set when paths are collected, so transient Azure API failures can leave
unreviewed_files_set empty even with new commits. PRReviewer.run then incorrectly skips the
review as “no new files.”
Code

pr_agent/git_providers/azuredevops_provider.py[R223-231]

+            try:
+                changes_obj = self.azure_devops_client.get_changes(
+                    project=self.workspace_slug,
+                    repository_id=self.repo_slug,
+                    commit_id=commit.commit_id,
+                )
+            except Exception as e:
+                get_logger().warning(f"Failed to fetch changes for {commit.commit_id}: {e}")
+                continue
Evidence
The Azure provider swallows get_changes failures and may end with no collected paths; the
reviewer’s skip branch uses only unreviewed_files_set emptiness as the signal to skip, which can
therefore be triggered by errors rather than “no changes”.

pr_agent/git_providers/azuredevops_provider.py[217-243]
pr_agent/git_providers/azuredevops_provider.py[244-250]
pr_agent/tools/pr_reviewer.py[142-150]

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

## Issue description
Incremental file discovery for Azure DevOps can incorrectly produce an empty `unreviewed_files_set` when `get_changes(...)` calls fail, causing `PRReviewer.run()` to skip the review as if there were no new files.
### Issue Context
In `_get_incremental_commits`, exceptions from `get_changes` are logged and the loop continues. If all commits error (or errors prevent any paths from being collected), `unreviewed_files_set` stays empty and the reviewer’s skip branch triggers.
### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[217-250]
- pr_agent/tools/pr_reviewer.py[142-150]
### Proposed change
Track whether any `get_changes` call failed (e.g., `had_errors=True`), and if `commits_range` is non-empty but no paths were collected *and* there were errors, disable incremental (`self.incremental.is_incremental = False`) so the run proceeds with a full review instead of skipping. Alternatively, in `PRReviewer.run`, only skip when the provider positively determined there were no changed files (vs. failing to compute them).

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


17. set(candidate_paths) loses ordering ✓ Resolved 📘 Rule violation ☼ Reliability
Description
The incremental file list deduplicates paths via set(...), which produces nondeterministic
ordering and can lead to unstable incremental review outputs when iterating/truncating file lists.
This violates the requirement for deterministic, order-preserving merges/deduplication for
order-sensitive collections.
Code

pr_agent/git_providers/azuredevops_provider.py[245]

+            filtered = filter_ignored(list(set(candidate_paths)), 'azure')
Evidence
PR Compliance ID 29 requires deterministic, order-preserving merging/deduplication for
order-sensitive collections; the new code converts candidate_paths into a set, which discards
order and reintroduces items in an arbitrary order.

pr_agent/git_providers/azuredevops_provider.py[245-245]
Best Practice: Learned patterns

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

## Issue description
`candidate_paths` is deduplicated using `set(...)`, which loses ordering and makes the resulting file iteration order nondeterministic.
## Issue Context
Incremental review file ordering can affect stable behavior (especially when iterating and/or truncating lists for token limits). The compliance checklist requires order-preserving merges/deduplication.
## Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[244-248]

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


18. Unreviewed files bypass filters ✓ Resolved 🐞 Bug ≡ Correctness
Description
AzureDevopsProvider._get_incremental_commits() adds paths to unreviewed_files_set without
applying filter_ignored() or is_valid_file(), but get_diff_files() later applies those
filters. This inconsistency can cause incremental mode to think there are “new files” while the diff
ends up empty or the main-language detection is skewed by ignored/invalid paths.
Code

pr_agent/git_providers/azuredevops_provider.py[R231-240]

+            for change in (getattr(changes_obj, "changes", None) or []):
+                try:
+                    item = change["item"]
+                except (KeyError, TypeError):
+                    item = getattr(change, "additional_properties", {}).get("item", {}) or {}
+                if not isinstance(item, dict) or item.get("gitObjectType") == "tree":
+                    continue
+                path = item.get("path")
+                if path:
+                    self.unreviewed_files_set[path] = path
Evidence
Incremental mode uses unreviewed_files_set to drive get_files() (and thus PR language detection
and some control flow), but the set is populated from commit changes without the same
ignore/validity filtering that get_diff_files() applies when building the actual diff list.

pr_agent/git_providers/azuredevops_provider.py[200-241]
pr_agent/git_providers/azuredevops_provider.py[291-297]
pr_agent/git_providers/azuredevops_provider.py[385-409]
pr_agent/tools/pr_reviewer.py[47-56]

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

## Issue description
In Azure incremental mode, `unreviewed_files_set` is populated with raw paths from commit changes, but `get_diff_files()` applies `filter_ignored(...)` and `is_valid_file(...)` later. This mismatch can make incremental mode appear active (non-empty unreviewed set) while producing no diff files, and can skew main-language detection because `PRReviewer.__init__` uses `git_provider.get_files()`.
### Issue Context
- `_get_incremental_commits()` currently adds every non-tree path.
- `get_diff_files()` filters ignored paths and invalid extensions.
- `get_files()` returns `unreviewed_files_set.keys()` when incremental.
### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[200-241]
- pr_agent/git_providers/azuredevops_provider.py[291-297]
- pr_agent/git_providers/azuredevops_provider.py[385-409]
### Suggested change
Apply `filter_ignored(..., 'azure')` and `is_valid_file(...)` when building (or immediately after building) `unreviewed_files_set`, so the incremental file list matches what `get_diff_files()` will actually consider.

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



Advisory comments

19. Single-quoted azure literal ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
A new string literal uses single quotes ('azure') instead of the repository’s preferred
double-quote style. This introduces inconsistent quoting and may fail style expectations.
Code

pr_agent/git_providers/azuredevops_provider.py[R247-249]

+            deduped = list(dict.fromkeys(candidate_paths))
+            filtered = filter_ignored(deduped, 'azure')
+            for path in filtered:
Evidence
PR Compliance ID 17 requires preferring double quotes in Python sources...

Comment thread pr_agent/git_providers/azuredevops_provider.py
Comment thread pr_agent/tools/pr_reviewer.py
- Guard against None additional_properties when parsing commit changes.
- Apply filter_ignored/is_valid_file when building unreviewed_files_set
  so it matches get_diff_files() filtering.
- When commits_range is None, disable incremental and proceed with a
  full review instead of returning early.
@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects Bot commented May 8, 2026

Persistent review updated to latest commit d2b3b96

Comment thread pr_agent/git_providers/azuredevops_provider.py Outdated
- get_previous_review() now selects the most recent matching PR-Agent
  review by published_date instead of returning the first match. The
  prior behavior could feed a stale timestamp into the commit-range
  computation when multiple reviews existed.
- Replace set(candidate_paths) with dict.fromkeys(...) so incremental
  file-list dedup is order-preserving and deterministic across runs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects Bot commented May 8, 2026

Persistent review updated to latest commit 63b58a6

Comment thread pr_agent/tools/pr_reviewer.py Outdated
… fallback

- _can_run_incremental_review() now returns False (not True) when
  incremental.commits_range is None, satisfying compliance ID 6 and
  the test_can_run_returns_false_when_commits_range_none unit test.
- PRReviewer.run() distinguishes "gate disabled incremental" (fall
  through to full review) from "gate said skip" (return early), so
  the previous fallback log line now produces an actual full review.
- _get_incremental_commits() tracks had_errors so that transient Azure
  get_changes failures disable incremental and fall back to a full
  review instead of being silently skipped as "no new files".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects Bot commented May 8, 2026

Persistent review updated to latest commit 4041c38

…map, polish

- get_diff_files: incremental path now substitutes for the base-SHA
  original fetch instead of fetching base then overwriting, saving one
  Azure get_item call per modified file in incremental runs.
- get_diff_files: cache guard switched to `is not None` so the
  early-return on missing last_merge_commit/last_merge_target_commit is
  cached (`[]` is falsy and was being recomputed each call).
- Rename `unreviewed_files_set` -> `unreviewed_files_map` across Azure,
  GitHub, Gitea providers, the reviewer, and the unit test. The
  attribute has always been a dict (path -> path/patch); the old name
  was misleading.
- Wrap get_previous_review max() lambda for readability.
- Normalize filter_ignored(..., "azure") to double-quoted literal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects Bot commented May 8, 2026

Persistent review updated to latest commit 55cd982

Comment thread pr_agent/git_providers/azuredevops_provider.py
…eliable

- _get_commit_range now returns None (not []) when the previous review
  has no timestamp, no PR commits exist, or every commit author date is
  None, and disables incremental so PRReviewer's existing
  `commits_range is None` fallback path runs a full review instead of
  silently exiting via the threshold check.
- _get_incremental_commits short-circuits when _get_commit_range
  returns None, avoiding TypeError on the iteration that follows.
- Wraps the over-120-char incremental-skip conditional in
  PRReviewer.run for ruff compliance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects Bot commented May 8, 2026

Persistent review updated to latest commit 2690d27

@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects Bot commented May 8, 2026

Persistent review updated to latest commit 5cddd81

Comment on lines +363 to 365
if self.diff_files is not None:
return self.diff_files

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

1. Stale diff cache reused 🐞 Bug ☼ Reliability

AzureDevopsProvider.get_diff_files() now returns cached self.diff_files whenever it’s not
None, but neither set_pr() nor get_incremental_commits() resets
diff_files/pr_commits/previous_review; if the provider instance is reused, incremental and
full reviews can return stale diffs and wrong review scope. This can cause incremental reviews to
unintentionally include the full PR diff (or vice versa), defeating the feature and potentially
reviewing incorrect content.
Agent Prompt
### Issue description
`AzureDevopsProvider` caches diff/commit-derived state (`diff_files`, `pr_commits`, `previous_review`, and `unreviewed_files_map`) but does not reset it when (a) a new PR is set via `set_pr()` or (b) incremental mode is enabled via `get_incremental_commits()`. In reused provider instances, `get_diff_files()` can return stale cached results and ignore the current incremental/full mode.

### Issue Context
The codebase has a context-based provider reuse mechanism; even if reuse is not always active, the provider should be safe under reuse to avoid returning wrong diffs.

### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[186-198]
- pr_agent/git_providers/azuredevops_provider.py[191-210]
- pr_agent/git_providers/azuredevops_provider.py[360-365]

### Suggested fix
- In `set_pr()`: clear PR-specific caches, e.g. `self.diff_files = None`, `self.pr_commits = None`, `self.previous_review = None`, and `self.unreviewed_files_map = {}`.
- In `get_incremental_commits()`: also clear `self.diff_files` (and potentially `self.pr_commits` if you need a fresh commit list) before computing incremental state, so incremental diff filtering/rebuilding is based on current data.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug + feature: -i (incremental review) crashes on Azure DevOps; needs full incremental support in AzureDevopsProvider

1 participant