Skip to content

Commit a7f152e

Browse files
agonzalesipcoop-cmykagonzales-ipcoopnaorpeled
authored
fix(azure-devops): implement incremental review (-i) support (#2379) (#2381)
Co-authored-by: Andy Gonzales <agonzales@ipcoop.com> Co-authored-by: naorpeled <me@naor.dev>
1 parent 6d43230 commit a7f152e

5 files changed

Lines changed: 521 additions & 25 deletions

File tree

pr_agent/git_providers/azuredevops_provider.py

Lines changed: 239 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import datetime as _dt
34
import os
45
from typing import Optional, Tuple
56
from urllib.parse import quote, unquote, urlparse
@@ -8,12 +9,12 @@
89

910
from ..algo.file_filter import filter_ignored
1011
from ..algo.language_handler import is_valid_file
11-
from ..algo.utils import (PRDescriptionHeader, clip_tokens,
12+
from ..algo.utils import (PRDescriptionHeader, PRReviewHeader, clip_tokens,
1213
find_line_number_of_relevant_line_in_file,
1314
load_large_diff)
1415
from ..config_loader import get_settings
1516
from ..log import get_logger
16-
from .git_provider import GitProvider
17+
from .git_provider import GitProvider, IncrementalPR
1718

1819
AZURE_DEVOPS_AVAILABLE = True
1920
ADO_APP_CLIENT_DEFAULT_ID = "499b84ac-1321-427f-aa17-267ca6975798/.default"
@@ -32,6 +33,32 @@
3233
AZURE_DEVOPS_AVAILABLE = False
3334

3435

36+
def _to_naive_utc(dt):
37+
if dt is None:
38+
return None
39+
if getattr(dt, "tzinfo", None) is not None:
40+
return dt.astimezone(_dt.timezone.utc).replace(tzinfo=None)
41+
return dt
42+
43+
44+
class _AzureCommitInner:
45+
def __init__(self, raw):
46+
self.message = getattr(raw, "comment", "") or ""
47+
author = getattr(raw, "author", None)
48+
author_date = _to_naive_utc(getattr(author, "date", None)) if author else None
49+
self.author = type("_AzureAuthor", (), {"date": author_date})()
50+
51+
52+
class _AzureCommitAdapter:
53+
"""Mimics PyGithub `Commit` shape (.sha, .commit.author.date, .commit.message, .parents)."""
54+
55+
def __init__(self, raw):
56+
self.sha = raw.commit_id
57+
self.commit_id = raw.commit_id
58+
self.commit = _AzureCommitInner(raw)
59+
self.parents = list(getattr(raw, "parents", None) or [])
60+
61+
3562
class AzureDevopsProvider(GitProvider):
3663

3764
def __init__(
@@ -51,6 +78,9 @@ def __init__(
5178
self.pr = None
5279
self.temp_comments = []
5380
self.incremental = incremental
81+
self.unreviewed_files_map = {}
82+
self.pr_commits = None
83+
self.previous_review = None
5484
if pr_url:
5585
self.set_pr(pr_url)
5686

@@ -158,6 +188,156 @@ def set_pr(self, pr_url: str):
158188
self.workspace_slug, self.repo_slug, self.pr_num = self._parse_pr_url(pr_url)
159189
self.pr = self._get_pr()
160190

191+
def get_incremental_commits(self, incremental=None):
192+
if incremental is None:
193+
incremental = IncrementalPR(False)
194+
self.incremental = incremental
195+
if self.incremental.is_incremental:
196+
# Invalidate any diff cache from a prior (full) get_diff_files() on a reused
197+
# provider instance, so incremental filtering/rebuild is recomputed rather than
198+
# returning the full-PR diff.
199+
self.diff_files = None
200+
self.unreviewed_files_map = {}
201+
self._get_incremental_commits()
202+
203+
def _get_incremental_commits(self):
204+
if not self.pr_commits:
205+
raw = list(self.azure_devops_client.get_pull_request_commits(
206+
project=self.workspace_slug,
207+
repository_id=self.repo_slug,
208+
pull_request_id=self.pr_num,
209+
))
210+
# Azure returns newest-first; oldest-first matches GitHub iteration order.
211+
raw.reverse()
212+
self.pr_commits = [_AzureCommitAdapter(c) for c in raw]
213+
214+
self.previous_review = self.get_previous_review(full=True, incremental=True)
215+
if not self.previous_review:
216+
get_logger().info("No previous review found, will review the entire PR")
217+
self.incremental.is_incremental = False
218+
return
219+
220+
self.incremental.commits_range = self._get_commit_range()
221+
if self.incremental.commits_range is None:
222+
return
223+
candidate_paths = []
224+
had_errors = False
225+
non_merge_seen = False
226+
for commit in self.incremental.commits_range:
227+
if len(commit.parents) > 1:
228+
get_logger().info(f"Skipping merge commit {commit.sha}")
229+
continue
230+
non_merge_seen = True
231+
try:
232+
changes_obj = self.azure_devops_client.get_changes(
233+
project=self.workspace_slug,
234+
repository_id=self.repo_slug,
235+
commit_id=commit.commit_id,
236+
)
237+
except Exception as e:
238+
had_errors = True
239+
get_logger().warning(f"Failed to fetch changes for {commit.commit_id}: {e}")
240+
continue
241+
for change in (getattr(changes_obj, "changes", None) or []):
242+
try:
243+
item = change["item"]
244+
except (KeyError, TypeError):
245+
additional = getattr(change, "additional_properties", None) or {}
246+
item = additional.get("item") or {}
247+
if not isinstance(item, dict) or item.get("gitObjectType") == "tree":
248+
continue
249+
path = item.get("path")
250+
if path:
251+
candidate_paths.append(path)
252+
253+
if candidate_paths:
254+
deduped = list(dict.fromkeys(candidate_paths))
255+
filtered = filter_ignored(deduped, "azure")
256+
for path in filtered:
257+
if is_valid_file(path):
258+
self.unreviewed_files_map[path] = path
259+
elif had_errors and self.incremental.commits_range:
260+
get_logger().warning(
261+
"Failed to fetch changes for incremental commits; falling back to full review."
262+
)
263+
self.incremental.is_incremental = False
264+
elif self.incremental.commits_range and not non_merge_seen:
265+
get_logger().info(
266+
"Incremental range only contains merge commits; falling back to full review."
267+
)
268+
self.incremental.is_incremental = False
269+
270+
def _get_commit_range(self):
271+
last_review_time = _to_naive_utc(getattr(self.previous_review, "created_at", None))
272+
if last_review_time is None or not self.pr_commits:
273+
get_logger().info(
274+
"Cannot compute incremental commit range "
275+
"(missing previous review timestamp or PR commits); falling back to full review."
276+
)
277+
self.incremental.is_incremental = False
278+
return None
279+
# Walk newest -> oldest to find the newest commit that predates the previous review
280+
# (the "last seen" baseline). The new range is then everything positioned after that
281+
# baseline, sliced by index — not by re-testing each commit's date — so that commits
282+
# with a missing author date (the adapter allows author.date to be None) are still
283+
# included rather than silently dropped from the incremental scope.
284+
last_seen_index = None
285+
saw_reliable_date = False
286+
for index in range(len(self.pr_commits) - 1, -1, -1):
287+
cdate = self.pr_commits[index].commit.author.date
288+
if cdate is None:
289+
continue
290+
saw_reliable_date = True
291+
if cdate <= last_review_time:
292+
last_seen_index = index
293+
self.incremental.last_seen_commit = self.pr_commits[index]
294+
break
295+
if not saw_reliable_date:
296+
get_logger().info(
297+
"All PR commit author dates are missing; cannot compute incremental range. "
298+
"Falling back to full review."
299+
)
300+
self.incremental.is_incremental = False
301+
return None
302+
# No commit predates the previous review, so there is no baseline to diff against.
303+
# Without it get_diff_files() cannot rebuild the incremental diff and would silently
304+
# fall back to the full PR diff while still claiming to be incremental — so degrade
305+
# explicitly to a full review instead.
306+
if last_seen_index is None:
307+
get_logger().info(
308+
"No PR commit predates the previous review (no last-seen baseline commit); "
309+
"falling back to full review."
310+
)
311+
self.incremental.is_incremental = False
312+
return None
313+
commits_range = self.pr_commits[last_seen_index + 1:]
314+
if commits_range:
315+
self.incremental.first_new_commit = commits_range[0]
316+
return commits_range
317+
318+
def get_previous_review(self, *, full: bool, incremental: bool):
319+
if not (full or incremental):
320+
raise ValueError("At least one of full or incremental must be True")
321+
prefixes = []
322+
if full:
323+
prefixes.append(PRReviewHeader.REGULAR.value)
324+
if incremental:
325+
prefixes.append(PRReviewHeader.INCREMENTAL.value)
326+
matches = []
327+
for comment in self.get_issue_comments():
328+
body = getattr(comment, "body", None)
329+
if body and any(body.startswith(p) for p in prefixes):
330+
matches.append(comment)
331+
if not matches:
332+
return None
333+
latest = max(
334+
matches,
335+
key=lambda c: _to_naive_utc(getattr(c, "published_date", None)) or _dt.datetime.min,
336+
)
337+
latest.html_url = self.get_comment_url(latest)
338+
latest.created_at = _to_naive_utc(getattr(latest, "published_date", None))
339+
return latest
340+
161341
def get_repo_settings(self):
162342
try:
163343
contents = self.azure_devops_client.get_item_content(
@@ -199,6 +379,13 @@ def get_repo_file_content(self, file_path: str, from_default_branch: bool = Fals
199379
return ""
200380

201381
def get_files(self):
382+
if (isinstance(getattr(self, "incremental", None), IncrementalPR)
383+
and self.incremental.is_incremental
384+
and self.unreviewed_files_map):
385+
return list(self.unreviewed_files_map.keys())
386+
return self._get_files_full()
387+
388+
def _get_files_full(self):
202389
files = []
203390
for i in self.azure_devops_client.get_pull_request_commits(
204391
project=self.workspace_slug,
@@ -218,9 +405,17 @@ def get_files(self):
218405
def get_diff_files(self) -> list[FilePatchInfo]:
219406
try:
220407

221-
if self.diff_files:
408+
if self.diff_files is not None:
222409
return self.diff_files
223410

411+
if self.pr.last_merge_commit is None or self.pr.last_merge_target_commit is None:
412+
get_logger().info(
413+
f"PR {self.pr_num} has no last_merge_commit/last_merge_target_commit; "
414+
f"cannot compute diff files."
415+
)
416+
self.diff_files = []
417+
return []
418+
224419
base_sha = self.pr.last_merge_target_commit
225420
head_sha = self.pr.last_merge_commit
226421

@@ -278,7 +473,7 @@ def get_diff_files(self) -> list[FilePatchInfo]:
278473
# diffs = list(set(diffs))
279474

280475
diffs_original = diffs
281-
diffs = filter_ignored(diffs_original, 'azure')
476+
diffs = filter_ignored(diffs_original, "azure")
282477
if diffs_original != diffs:
283478
try:
284479
get_logger().info(f"Filtered out [ignore] files for pull request:", extra=
@@ -287,6 +482,15 @@ def get_diff_files(self) -> list[FilePatchInfo]:
287482
except Exception:
288483
pass
289484

485+
incremental_active = (
486+
isinstance(getattr(self, "incremental", None), IncrementalPR)
487+
and self.incremental.is_incremental
488+
and bool(self.unreviewed_files_map)
489+
and self.incremental.last_seen_commit_sha
490+
)
491+
if incremental_active:
492+
diffs = [f for f in diffs if f in self.unreviewed_files_map]
493+
290494
invalid_files_names = []
291495
for file in diffs:
292496
if not is_valid_file(file):
@@ -325,29 +529,53 @@ def get_diff_files(self) -> list[FilePatchInfo]:
325529
elif "rename" in diff_types[file]: # diff_type can be `rename` | `edit, rename`
326530
edit_type = EDIT_TYPE.RENAMED
327531

328-
version = GitVersionDescriptor(
329-
version=base_sha.commit_id, version_type="commit"
330-
)
331532
if edit_type == EDIT_TYPE.ADDED or edit_type == EDIT_TYPE.RENAMED:
332533
original_file_content_str = ""
534+
elif incremental_active:
535+
inc_version = GitVersionDescriptor(
536+
version=self.incremental.last_seen_commit_sha, version_type="commit"
537+
)
538+
try:
539+
inc_original = self.azure_devops_client.get_item(
540+
repository_id=self.repo_slug,
541+
path=file,
542+
project=self.workspace_slug,
543+
version_descriptor=inc_version,
544+
download=False,
545+
include_content=True,
546+
)
547+
original_file_content_str = inc_original.content or ""
548+
except Exception as error:
549+
get_logger().warning(
550+
f"Failed to retrieve original of {file} at {self.incremental.last_seen_commit_sha}: {error}"
551+
)
552+
original_file_content_str = ""
333553
else:
554+
base_version = GitVersionDescriptor(
555+
version=base_sha.commit_id, version_type="commit"
556+
)
334557
try:
335-
original_file_content_str = self.azure_devops_client.get_item(
558+
base_original = self.azure_devops_client.get_item(
336559
repository_id=self.repo_slug,
337560
path=file,
338561
project=self.workspace_slug,
339-
version_descriptor=version,
562+
version_descriptor=base_version,
340563
download=False,
341564
include_content=True,
342565
)
343-
original_file_content_str = original_file_content_str.content
566+
original_file_content_str = base_original.content
344567
except Exception as error:
345-
get_logger().error(f"Failed to retrieve original file content of {file} at version {version}", error=error)
568+
get_logger().error(
569+
f"Failed to retrieve original file content of {file} at version {base_version}",
570+
error=error,
571+
)
346572
original_file_content_str = ""
347573

348574
patch = load_large_diff(
349575
file, new_file_content_str, original_file_content_str, show_warning=False
350576
).rstrip()
577+
if incremental_active:
578+
self.unreviewed_files_map[file] = patch
351579

352580
# count number of lines added and removed
353581
patch_lines = patch.splitlines(keepends=True)

pr_agent/git_providers/gitea_provider.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def __init__(self, url: Optional[str] = None):
6767
self.diff_files = []
6868
self.incremental = IncrementalPR(False)
6969
self.comments_list = []
70-
self.unreviewed_files_set = dict()
70+
self.unreviewed_files_map = dict()
7171

7272
if "pulls" in url:
7373
self.pr_url = url
@@ -478,9 +478,9 @@ def get_diff_files(self) -> List[FilePatchInfo]:
478478
# Get file content from this pr
479479
head_file = self.file_contents.get(filename,"")
480480

481-
if self.incremental.is_incremental and self.unreviewed_files_set:
481+
if self.incremental.is_incremental and self.unreviewed_files_map:
482482
base_file = self._get_file_content_from_latest_commit(filename)
483-
self.unreviewed_files_set[filename] = patch
483+
self.unreviewed_files_map[filename] = patch
484484
else:
485485
if avoid_load:
486486
base_file = ""

0 commit comments

Comments
 (0)