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.
- agentic_devtools/_bundled_skills/skills/ai-pr-loop-dispatch/SKILL.md +8 -5
- agentic_devtools/_bundled_skills/skills/ai-pr-loop-dispatch/render_dispatch.py +58 -12
- agentic_devtools/_version.py +2 -2
- agentic_devtools/cli/azure_devops/helpers.py +7 -1
- agentic_devtools/cli/azure_devops/pull_request_details_commands.py +19 -17
- agentic_devtools/cli/azure_devops/review_commands.py +89 -67
- agentic_devtools/cli/git/operations.py +34 -25
- agentic_devtools/cli/workflows/base.py +33 -1
- agentic_devtools/cli/workflows/commands.py +37 -34
- agentic_devtools/cli/workflows/preflight.py +12 -11
- agentic_devtools/prompts/pull-request-review/default-delegate-prompt.md +13 -3
- agentic_devtools/prompts/pull-request-review/default-pr-synthesis-prompt.md +18 -0
- {agentic_devtools-0.2.485.dist-info → agentic_devtools-0.2.487.dist-info}/METADATA +1 -1
- {agentic_devtools-0.2.485.dist-info → agentic_devtools-0.2.487.dist-info}/RECORD +17 -17
- {agentic_devtools-0.2.485.dist-info → agentic_devtools-0.2.487.dist-info}/WHEEL +0 -0
- {agentic_devtools-0.2.485.dist-info → agentic_devtools-0.2.487.dist-info}/entry_points.txt +0 -0
- {agentic_devtools-0.2.485.dist-info → agentic_devtools-0.2.487.dist-info}/licenses/LICENSE +0 -0
|
@@ -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.
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
106
|
-
|
|
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]) ->
|
|
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=
|
|
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=
|
|
1803
|
+
_run_takeover_after_monitor(owner=owner, repo=repo, pr_number=pr_number, provider=_fresh_provider())
|
|
1758
1804
|
return 0
|
|
1759
1805
|
|
|
1760
1806
|
|
agentic_devtools/_version.py
CHANGED
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
511
|
+
_safe_print(f" Auto-Complete: Set by {pr_data['autoCompleteSetBy'].get('displayName')}")
|
|
510
512
|
else:
|
|
511
|
-
|
|
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
|
-
|
|
597
|
+
_safe_print(f"Captured {len(files_details)} file entries for comparison.")
|
|
596
598
|
if reviewed_count > 0: # pragma: no cover
|
|
597
|
-
|
|
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
|
-
|
|
616
|
+
_safe_print(f"\nResponse (with file diffs) saved to: {output_file}")
|