agentic-devtools 0.2.485__py3-none-any.whl → 0.2.487__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,9 +54,11 @@ Review requests remain outside this workflow.
54
54
  Use `--resume` with the same review URL to reuse an existing monitor state file, skip tasks
55
55
  whose assigned comments are already complete, and continue only unfinished comments without
56
56
  creating duplicate Agent Tasks.
57
- 4. Confirm the helper reports one output path and accepted Agent Task URL per finding. The task payload
58
- must use custom agent `agdt.address-copilot-review.evaluate-and-respond`, the PR base ref, the
59
- PR head ref, `create_pull_request=false`, and model `gpt-5.6-luna` unless the user overrides it.
57
+ 4. Confirm the helper reports one output path and accepted Agent Task URL per unresolved finding.
58
+ Resolved review threads are excluded; if every recovered finding is already resolved, the helper
59
+ exits successfully as a no-op without rendering or dispatching tasks. The task payload must use
60
+ custom agent `agdt.address-copilot-review.evaluate-and-respond`, the PR base ref, the PR head ref,
61
+ `create_pull_request=false`, and model `gpt-5.6-luna` unless the user overrides it.
60
62
  5. Create or reuse one unresolved review-comment thread for each suppressed finding before its
61
63
  task is dispatched. Anchor it to the PR head when a line is available, otherwise create a
62
64
  file-level review comment. Include the returned discussion URL in that task's rendered prompt.
@@ -102,8 +104,9 @@ Review requests remain outside this workflow.
102
104
  against a fresh PR snapshot. Let its normal preconditions decide whether a human re-authored
103
105
  push is needed; report a failed takeover rather than claiming completion.
104
106
  10. Return one generated `.agdt-temp/ai-pr-loop-dispatch-<PR>-<REVIEW>-comment-<N>.md` link and
105
- one accepted Agent Task URL per finding. Mention the per-thread tracking replies, monitoring
106
- state file, and takeover result. A reply update failure must not cause task creation to repeat.
107
+ one accepted Agent Task URL per unresolved finding. If all findings were resolved, report the
108
+ successful no-op instead. Mention the per-thread tracking replies, monitoring state file, and
109
+ takeover result. A reply update failure must not cause task creation to repeat.
107
110
  Sequential dispatch is the default; parallel dispatch is available only through the explicit
108
111
  `--parallel` option.
109
112
 
@@ -26,6 +26,7 @@ if str(_REPOSITORY_ROOT) not in sys.path:
26
26
  sys.path.insert(0, str(_REPOSITORY_ROOT))
27
27
 
28
28
  from agentic_devtools.cli.ci.evaluator.diff_heuristic import check_lines_modified # noqa: E402
29
+ from agentic_devtools.cli.ci.review_thread_state import fetch_review_thread_states # noqa: E402
29
30
 
30
31
  _REVIEW_URL_PATTERN = re.compile(
31
32
  r"^https://github\.com/(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)"
@@ -511,6 +512,26 @@ def _comment_metadata(comment: Any, review_url: str) -> dict[str, Any]:
511
512
  }
512
513
 
513
514
 
515
+ def _filter_unresolved_review_comments(*, provider: Any, pr_number: int, comments: list[Any]) -> list[Any]:
516
+ """Discard comments whose review thread is already resolved.
517
+
518
+ Suppressed findings use synthetic negative IDs until a thread is created, so they
519
+ remain eligible for dispatch. Providers without thread-state support retain the
520
+ previous behavior and return the recovered comments unchanged.
521
+ """
522
+ result = fetch_review_thread_states(provider, pr_number)
523
+ if result.degraded:
524
+ return comments
525
+ thread_states = result.states
526
+ return [
527
+ comment
528
+ for comment in comments
529
+ if not isinstance(getattr(comment, "id", None), int)
530
+ or comment.id < 0
531
+ or not thread_states.get(comment.id, (False, False))[0]
532
+ ]
533
+
534
+
514
535
  def _parse_follow_up_issue(task_text: str) -> dict[str, Any] | None:
515
536
  """Parse the exact structured option-four issue block emitted by the repair agent."""
516
537
  marker_index = task_text.find(_FOLLOW_UP_MARKER)
@@ -1526,6 +1547,14 @@ def main() -> int:
1526
1547
 
1527
1548
  args = _build_parser().parse_args()
1528
1549
  owner, repo, pr_number, review_id = _parse_review_url(args.review_url)
1550
+ if args.post_task_link and not args.dispatch_task:
1551
+ raise ValueError("--post-task-link requires --dispatch-task")
1552
+ if args.monitor and not args.post_task_link:
1553
+ raise ValueError("--monitor requires --post-task-link")
1554
+ if args.resume and not args.post_task_link:
1555
+ raise ValueError("--resume requires --post-task-link")
1556
+ if args.poll_interval_seconds < 0:
1557
+ raise ValueError("--poll-interval-seconds must be non-negative")
1529
1558
  review_body = _load_review_body(
1530
1559
  content_file=args.content_file,
1531
1560
  owner=owner,
@@ -1535,11 +1564,22 @@ def main() -> int:
1535
1564
  )
1536
1565
  suppressed_comments = _parse_suppressed_from_review_body(review_body, source_review_id=review_id)
1537
1566
  provider = GitHubActionsProvider(f"{owner}/{repo}")
1567
+
1568
+ def _fresh_provider() -> Any:
1569
+ return GitHubActionsProvider(f"{owner}/{repo}")
1570
+
1538
1571
  review_comments = provider.list_review_comments(pr_number, review_id)
1539
1572
  review_comments = _deduplicate_review_comments(review_comments, suppressed_comments)
1540
1573
  review_comments.sort(key=lambda comment: not (comment.is_suppressed and not comment.html_url))
1541
1574
  if not review_comments:
1542
1575
  raise RuntimeError("No inline or suppressed CCR findings were recovered from the review.")
1576
+ review_comments = _filter_unresolved_review_comments(
1577
+ provider=provider,
1578
+ pr_number=pr_number,
1579
+ comments=review_comments,
1580
+ )
1581
+ if not review_comments:
1582
+ return 0
1543
1583
  head_sha = _load_head_sha(
1544
1584
  owner=owner,
1545
1585
  repo=repo,
@@ -1547,14 +1587,6 @@ def main() -> int:
1547
1587
  supplied=args.head_sha,
1548
1588
  )
1549
1589
  args.output_dir.mkdir(parents=True, exist_ok=True)
1550
- if args.post_task_link and not args.dispatch_task:
1551
- raise ValueError("--post-task-link requires --dispatch-task")
1552
- if args.monitor and not args.post_task_link:
1553
- raise ValueError("--monitor requires --post-task-link")
1554
- if args.resume and not args.post_task_link:
1555
- raise ValueError("--resume requires --post-task-link")
1556
- if args.poll_interval_seconds < 0:
1557
- raise ValueError("--poll-interval-seconds must be non-negative")
1558
1590
  dispatch_config: tuple[str, str, str] | None = None
1559
1591
  if args.dispatch_task:
1560
1592
  base_ref = _load_pr_ref(
@@ -1603,6 +1635,9 @@ def main() -> int:
1603
1635
  marker=marker,
1604
1636
  provider=provider,
1605
1637
  )
1638
+ thread_states = fetch_review_thread_states(_fresh_provider(), pr_number)
1639
+ if not thread_states.degraded and thread_states.states.get(comment_id, (False, False))[0]:
1640
+ continue
1606
1641
  dispatch_comment = replace(comment, id=comment_id, html_url=html_url, is_suppressed=False)
1607
1642
  record.update(
1608
1643
  {
@@ -1613,7 +1648,16 @@ def main() -> int:
1613
1648
  )
1614
1649
  prepared_comments.append((dispatch_comment, record))
1615
1650
 
1616
- def _render_and_dispatch(ordinal: int, comment: Any, record: dict[str, Any]) -> None:
1651
+ def _render_and_dispatch(ordinal: int, comment: Any, record: dict[str, Any]) -> bool:
1652
+ if dispatch_config is not None:
1653
+ thread_states = fetch_review_thread_states(_fresh_provider(), pr_number)
1654
+ if (
1655
+ not thread_states.degraded
1656
+ and isinstance(getattr(comment, "id", None), int)
1657
+ and comment.id >= 0
1658
+ and thread_states.states.get(comment.id, (False, False))[0]
1659
+ ):
1660
+ return False
1617
1661
  body = _build_repair_comment(
1618
1662
  head_sha=head_sha,
1619
1663
  repair_type="review",
@@ -1657,6 +1701,7 @@ def main() -> int:
1657
1701
  }
1658
1702
  )
1659
1703
  task_records.append(record)
1704
+ return True
1660
1705
 
1661
1706
  def _write_initial_state() -> Path:
1662
1707
  state_path = _state_path(args.output_dir, pr_number, review_id)
@@ -1676,7 +1721,8 @@ def main() -> int:
1676
1721
  resume_record = _find_resume_record(resume_records, prepared_record) if args.resume else None
1677
1722
  if resume_record is None:
1678
1723
  record = prepared_record
1679
- _render_and_dispatch(ordinal, comment, record)
1724
+ if not _render_and_dispatch(ordinal, comment, record):
1725
+ continue
1680
1726
  else:
1681
1727
  record = resume_record
1682
1728
  record["ordinal"] = ordinal
@@ -1709,7 +1755,7 @@ def main() -> int:
1709
1755
  completion_records=[record],
1710
1756
  )
1711
1757
  if args.monitor and _tasks_ready_for_takeover(task_records):
1712
- _run_takeover_after_monitor(owner=owner, repo=repo, pr_number=pr_number, provider=provider)
1758
+ _run_takeover_after_monitor(owner=owner, repo=repo, pr_number=pr_number, provider=_fresh_provider())
1713
1759
  else:
1714
1760
  for ordinal, (comment, prepared_record) in enumerate(prepared_comments, start=1):
1715
1761
  resume_record = _find_resume_record(resume_records, prepared_record) if args.resume else None
@@ -1754,7 +1800,7 @@ def main() -> int:
1754
1800
  provider=provider,
1755
1801
  )
1756
1802
  if _tasks_ready_for_takeover(task_records):
1757
- _run_takeover_after_monitor(owner=owner, repo=repo, pr_number=pr_number, provider=provider)
1803
+ _run_takeover_after_monitor(owner=owner, repo=repo, pr_number=pr_number, provider=_fresh_provider())
1758
1804
  return 0
1759
1805
 
1760
1806
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.485'
22
- __version_tuple__ = version_tuple = (0, 2, 485)
21
+ __version__ = version = '0.2.487'
22
+ __version_tuple__ = version_tuple = (0, 2, 487)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -15,7 +15,7 @@ from pathlib import Path
15
15
  from typing import Any
16
16
  from urllib.parse import quote, unquote
17
17
 
18
- from ...state import get_state_dir, is_safe_dir_segment, read_modify_write_state
18
+ from ...state import get_state_dir, get_value, is_safe_dir_segment, read_modify_write_state
19
19
  from ..subprocess_utils import run_safe
20
20
  from .auth import get_auth_headers, get_pat
21
21
  from .config import (
@@ -1149,6 +1149,12 @@ def resolve_review_artifact_dir_name(
1149
1149
  file=sys.stderr,
1150
1150
  )
1151
1151
  return fallback
1152
+ configured_dir_name = get_value("review.artifact_dir_name")
1153
+ configured_pr_id = get_value("review.artifact_dir_pr_id")
1154
+ if isinstance(configured_dir_name, str) and str(configured_pr_id) == str(pull_request_id):
1155
+ configured_dir_name = configured_dir_name.strip()
1156
+ if configured_dir_name and is_safe_dir_segment(configured_dir_name):
1157
+ return configured_dir_name
1152
1158
  candidate = _discover_commit_hash_short_fallback(pull_request_id, get_state_dir())
1153
1159
  if candidate is not None:
1154
1160
  resolved = candidate
@@ -454,6 +454,8 @@ def get_pull_request_details() -> None:
454
454
  Raises:
455
455
  SystemExit: On validation or execution errors.
456
456
  """
457
+ from ..workflows.base import _safe_print
458
+
457
459
  config = AzureDevOpsConfig.from_state()
458
460
  dry_run = is_dry_run()
459
461
  pull_request_id = get_pull_request_id(required=True)
@@ -466,17 +468,17 @@ def get_pull_request_details() -> None:
466
468
  temp_dir.mkdir(parents=True, exist_ok=True)
467
469
 
468
470
  if dry_run:
469
- print(f"DRY-RUN: Would retrieve pull request details for PR {pull_request_id}")
470
- print(f" Organization: {config.organization}")
471
- print(f" Project: {config.project}")
472
- print(f" Repository: {config.repository}")
473
- print(f" Output: {output_file}")
471
+ _safe_print(f"DRY-RUN: Would retrieve pull request details for PR {pull_request_id}")
472
+ _safe_print(f" Organization: {config.organization}")
473
+ _safe_print(f" Project: {config.project}")
474
+ _safe_print(f" Repository: {config.repository}")
475
+ _safe_print(f" Output: {output_file}")
474
476
  return
475
477
 
476
478
  pat = get_pat()
477
479
  headers = get_auth_headers(pat)
478
480
 
479
- print(f"Retrieving pull request details for PR {pull_request_id}...")
481
+ _safe_print(f"Retrieving pull request details for PR {pull_request_id}...")
480
482
 
481
483
  # Fetch the PR object via the project-scoped Azure DevOps REST API.
482
484
  #
@@ -491,7 +493,7 @@ def get_pull_request_details() -> None:
491
493
  config = replace(config, organization=_normalize_org_url(config.organization))
492
494
  pr_data = fetch_pull_request_via_rest(pull_request_id, config, headers)
493
495
  if not pr_data:
494
- print(
496
+ _safe_print(
495
497
  f"Error: Failed to get pull request details for PR {pull_request_id}. "
496
498
  "Verify the PAT (AZURE_DEV_OPS_COPILOT_PAT / AZURE_DEVOPS_EXT_PAT) is "
497
499
  f"valid and can access {config.organization}/{config.project}/{config.repository}.",
@@ -499,16 +501,16 @@ def get_pull_request_details() -> None:
499
501
  )
500
502
  sys.exit(1)
501
503
 
502
- print("Pull request details retrieved successfully:")
503
- print(f" PR ID: {pr_data.get('pullRequestId')}")
504
- print(f" Title: {pr_data.get('title')}")
505
- print(f" Is Draft: {pr_data.get('isDraft')}")
506
- print(f" Status: {pr_data.get('status')}")
504
+ _safe_print("Pull request details retrieved successfully:")
505
+ _safe_print(f" PR ID: {pr_data.get('pullRequestId')}")
506
+ _safe_print(f" Title: {pr_data.get('title')}")
507
+ _safe_print(f" Is Draft: {pr_data.get('isDraft')}")
508
+ _safe_print(f" Status: {pr_data.get('status')}")
507
509
 
508
510
  if pr_data.get("autoCompleteSetBy"):
509
- print(f" Auto-Complete: Set by {pr_data['autoCompleteSetBy'].get('displayName')}")
511
+ _safe_print(f" Auto-Complete: Set by {pr_data['autoCompleteSetBy'].get('displayName')}")
510
512
  else:
511
- print(" Auto-Complete: Not set")
513
+ _safe_print(" Auto-Complete: Not set")
512
514
 
513
515
  # Extract branch info
514
516
  target_branch = normalize_ref_name(pr_data.get("targetRefName"))
@@ -592,9 +594,9 @@ def get_pull_request_details() -> None:
592
594
  if reviewer_payload and reviewer_payload.get("reviewedFiles"): # pragma: no cover
593
595
  reviewed_count = len(reviewer_payload["reviewedFiles"])
594
596
 
595
- print(f"Captured {len(files_details)} file entries for comparison.")
597
+ _safe_print(f"Captured {len(files_details)} file entries for comparison.")
596
598
  if reviewed_count > 0: # pragma: no cover
597
- print(f"Found {reviewed_count} files already reviewed on latest iteration.")
599
+ _safe_print(f"Found {reviewed_count} files already reviewed on latest iteration.")
598
600
 
599
601
  # Build output payload
600
602
  output_payload = {
@@ -611,4 +613,4 @@ def get_pull_request_details() -> None:
611
613
  with open(output_file, "w", encoding="utf-8") as f:
612
614
  json.dump(output_payload, f, indent=2)
613
615
 
614
- print(f"\nResponse (with file diffs) saved to: {output_file}")
616
+ _safe_print(f"\nResponse (with file diffs) saved to: {output_file}")