11from __future__ import annotations
22
3+ import datetime as _dt
34import os
45from typing import Optional , Tuple
56from urllib .parse import quote , unquote , urlparse
89
910from ..algo .file_filter import filter_ignored
1011from ..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 )
1415from ..config_loader import get_settings
1516from ..log import get_logger
16- from .git_provider import GitProvider
17+ from .git_provider import GitProvider , IncrementalPR
1718
1819AZURE_DEVOPS_AVAILABLE = True
1920ADO_APP_CLIENT_DEFAULT_ID = "499b84ac-1321-427f-aa17-267ca6975798/.default"
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+
3562class 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 )
0 commit comments