agentic-devtools 0.2.332__py3-none-any.whl → 0.2.333__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/_version.py +2 -2
- agentic_devtools/adapters/__init__.py +8 -0
- agentic_devtools/adapters/idempotency_query_provider.py +64 -0
- agentic_devtools/adapters/issue_provider.py +65 -0
- agentic_devtools/adapters/jira_provider.py +140 -75
- agentic_devtools/adapters/operation_plan.py +111 -0
- agentic_devtools/adapters/orchestration_key.py +39 -15
- agentic_devtools/adapters/plan_manifest.py +437 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.333.dist-info}/METADATA +1 -1
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.333.dist-info}/RECORD +13 -10
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.333.dist-info}/WHEEL +0 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.333.dist-info}/entry_points.txt +0 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.333.dist-info}/licenses/LICENSE +0 -0
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.333'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 333)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -31,6 +31,7 @@ from agentic_devtools.adapters.base import (
|
|
|
31
31
|
from agentic_devtools.adapters.exceptions import AdapterError, AdapterValidationError
|
|
32
32
|
from agentic_devtools.adapters.factory import get_issue_provider
|
|
33
33
|
from agentic_devtools.adapters.github_adapter import GitHubIssuesAdapter
|
|
34
|
+
from agentic_devtools.adapters.idempotency_query_provider import IdempotencyQueryProvider
|
|
34
35
|
from agentic_devtools.adapters.issue_provider import (
|
|
35
36
|
VALID_ISSUE_TYPES,
|
|
36
37
|
InMemoryIssueProvider,
|
|
@@ -50,7 +51,9 @@ from agentic_devtools.adapters.issue_type_mapping import (
|
|
|
50
51
|
)
|
|
51
52
|
from agentic_devtools.adapters.jira_adapter import JiraAdapter
|
|
52
53
|
from agentic_devtools.adapters.markdown_adapter import MarkdownAdapter
|
|
54
|
+
from agentic_devtools.adapters.operation_plan import OperationDescriptor, OperationPlan
|
|
53
55
|
from agentic_devtools.adapters.orchestration_key import generate_orchestration_key
|
|
56
|
+
from agentic_devtools.adapters.plan_manifest import execute_manifest, plan_manifest
|
|
54
57
|
from agentic_devtools.config import load_platform_config
|
|
55
58
|
from agentic_devtools.tools.jira import JiraConfig
|
|
56
59
|
|
|
@@ -61,6 +64,7 @@ __all__ = [
|
|
|
61
64
|
"CommentResult",
|
|
62
65
|
"GitHubIssuesAdapter",
|
|
63
66
|
"GitHubMappingResult",
|
|
67
|
+
"IdempotencyQueryProvider",
|
|
64
68
|
"InMemoryIssueProvider",
|
|
65
69
|
"IssueAdapter",
|
|
66
70
|
"IssueDetail",
|
|
@@ -75,11 +79,14 @@ __all__ = [
|
|
|
75
79
|
"JiraMappingResult",
|
|
76
80
|
"MarkdownAdapter",
|
|
77
81
|
"NormalizedIssue",
|
|
82
|
+
"OperationDescriptor",
|
|
83
|
+
"OperationPlan",
|
|
78
84
|
"PropertySchema",
|
|
79
85
|
"ProviderIssueResult",
|
|
80
86
|
"ProviderLinkResult",
|
|
81
87
|
"TypeMapping",
|
|
82
88
|
"VALID_ISSUE_TYPES",
|
|
89
|
+
"execute_manifest",
|
|
83
90
|
"generate_orchestration_key",
|
|
84
91
|
"get_adapter",
|
|
85
92
|
"get_issue_provider",
|
|
@@ -87,6 +94,7 @@ __all__ = [
|
|
|
87
94
|
"load_jira_type_mapping",
|
|
88
95
|
"map_issue_type_to_github_labels",
|
|
89
96
|
"map_issue_type_to_jira",
|
|
97
|
+
"plan_manifest",
|
|
90
98
|
]
|
|
91
99
|
|
|
92
100
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""IdempotencyQueryProvider protocol for adapter-level existence queries.
|
|
2
|
+
|
|
3
|
+
Separated from ``IssueProvider`` (which has exactly 8 methods) to avoid
|
|
4
|
+
a breaking change to the mutation protocol. Used by the orchestrator
|
|
5
|
+
when ``dry_run=True`` and ``check_existing=True``.
|
|
6
|
+
|
|
7
|
+
All ``*_provider_id`` parameters accept provider-native string identifiers
|
|
8
|
+
(e.g., ``"42"`` for GitHub, ``"PROJ-123"`` for Jira).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from agentic_devtools.adapters.issue_provider import ProviderIssueResult, ProviderLinkResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class IdempotencyQueryProvider(Protocol):
|
|
20
|
+
"""Companion protocol for adapter-level idempotency queries.
|
|
21
|
+
|
|
22
|
+
Separated from IssueProvider (which has exactly 8 methods) to avoid
|
|
23
|
+
a breaking change to the mutation protocol. Used by the orchestrator
|
|
24
|
+
when dry_run=True and check_existing=True.
|
|
25
|
+
|
|
26
|
+
All *_provider_id parameters accept provider-native string identifiers
|
|
27
|
+
(e.g., "42" for GitHub, "PROJ-123" for Jira).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def find_existing_issue(
|
|
31
|
+
self,
|
|
32
|
+
orchestration_key: str,
|
|
33
|
+
) -> ProviderIssueResult | None:
|
|
34
|
+
"""Find an issue by its embedded orchestration key.
|
|
35
|
+
|
|
36
|
+
Returns ProviderIssueResult with status="existing" if found,
|
|
37
|
+
None if not found. Raises ValueError if multiple matches found
|
|
38
|
+
(ambiguous state — FR-008).
|
|
39
|
+
"""
|
|
40
|
+
... # pragma: no cover
|
|
41
|
+
|
|
42
|
+
def find_existing_link(
|
|
43
|
+
self,
|
|
44
|
+
parent_provider_id: str,
|
|
45
|
+
child_provider_id: str,
|
|
46
|
+
) -> ProviderLinkResult | None:
|
|
47
|
+
"""Check if a parent-child link already exists.
|
|
48
|
+
|
|
49
|
+
Returns ProviderLinkResult with status="already-linked" if found,
|
|
50
|
+
None if not found.
|
|
51
|
+
"""
|
|
52
|
+
... # pragma: no cover
|
|
53
|
+
|
|
54
|
+
def find_existing_dependency(
|
|
55
|
+
self,
|
|
56
|
+
issue_provider_id: str,
|
|
57
|
+
blocked_by_provider_id: str,
|
|
58
|
+
) -> ProviderLinkResult | None:
|
|
59
|
+
"""Check if a blocking dependency already exists.
|
|
60
|
+
|
|
61
|
+
Returns ProviderLinkResult with status="already-linked" if found,
|
|
62
|
+
None if not found.
|
|
63
|
+
"""
|
|
64
|
+
... # pragma: no cover
|
|
@@ -703,3 +703,68 @@ class InMemoryIssueProvider:
|
|
|
703
703
|
return f"#{identifier}"
|
|
704
704
|
return identifier
|
|
705
705
|
return identifier
|
|
706
|
+
|
|
707
|
+
# -- IdempotencyQueryProvider methods --
|
|
708
|
+
|
|
709
|
+
def find_existing_issue(self, orchestration_key: str) -> ProviderIssueResult | None:
|
|
710
|
+
"""Find issue by orchestration key embedded in body.
|
|
711
|
+
|
|
712
|
+
Searches all stored issues for a body containing the given key.
|
|
713
|
+
Also checks the idempotency_keys mapping.
|
|
714
|
+
|
|
715
|
+
Raises:
|
|
716
|
+
ValueError: If multiple issues match (ambiguous state — FR-008).
|
|
717
|
+
"""
|
|
718
|
+
from agentic_devtools.adapters.orchestration_key import extract_orchestration_key
|
|
719
|
+
|
|
720
|
+
matches: list[str] = []
|
|
721
|
+
for identifier, data in self._issues.items():
|
|
722
|
+
body_val = data.get("body")
|
|
723
|
+
key = extract_orchestration_key(body_val if isinstance(body_val, str) else "")
|
|
724
|
+
if key == orchestration_key:
|
|
725
|
+
matches.append(identifier)
|
|
726
|
+
if len(matches) > 1:
|
|
727
|
+
raise ValueError(f"Ambiguous: key {orchestration_key!r} matched {len(matches)} issues: {matches}")
|
|
728
|
+
if not matches:
|
|
729
|
+
# Check idempotency_keys mapping as fallback
|
|
730
|
+
ident = self._idempotency_keys.get(orchestration_key)
|
|
731
|
+
if ident is not None:
|
|
732
|
+
matches.append(ident)
|
|
733
|
+
if not matches:
|
|
734
|
+
return None
|
|
735
|
+
ident = matches[0]
|
|
736
|
+
return ProviderIssueResult(
|
|
737
|
+
identifier=ident,
|
|
738
|
+
url=f"https://fake.test/issues/{ident}",
|
|
739
|
+
status="existing",
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
def find_existing_link(self, parent_provider_id: str, child_provider_id: str) -> ProviderLinkResult | None:
|
|
743
|
+
"""Check if a parent-child link already exists.
|
|
744
|
+
|
|
745
|
+
Returns ProviderLinkResult with status="already-linked" if found,
|
|
746
|
+
None if not found.
|
|
747
|
+
"""
|
|
748
|
+
if (parent_provider_id, child_provider_id) in self._parent_child:
|
|
749
|
+
return ProviderLinkResult(
|
|
750
|
+
source_id=parent_provider_id,
|
|
751
|
+
target_id=child_provider_id,
|
|
752
|
+
status="already-linked",
|
|
753
|
+
)
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
def find_existing_dependency(
|
|
757
|
+
self, issue_provider_id: str, blocked_by_provider_id: str
|
|
758
|
+
) -> ProviderLinkResult | None:
|
|
759
|
+
"""Check if a blocking dependency already exists.
|
|
760
|
+
|
|
761
|
+
Returns ProviderLinkResult with status="already-linked" if found,
|
|
762
|
+
None if not found.
|
|
763
|
+
"""
|
|
764
|
+
if (issue_provider_id, blocked_by_provider_id) in self._blocked_by:
|
|
765
|
+
return ProviderLinkResult(
|
|
766
|
+
source_id=blocked_by_provider_id,
|
|
767
|
+
target_id=issue_provider_id,
|
|
768
|
+
status="already-linked",
|
|
769
|
+
)
|
|
770
|
+
return None
|
|
@@ -550,33 +550,12 @@ class JiraProvider:
|
|
|
550
550
|
)
|
|
551
551
|
|
|
552
552
|
# Resolve the epic-link custom field once (cached after first call).
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
get_url = self._api_url(f"/issue/{child_id}?fields={epic_field},parent")
|
|
558
|
-
get_resp = self._request("GET", get_url)
|
|
559
|
-
if get_resp.status_code == 404:
|
|
560
|
-
raise ValueError(f"Issue '{child_id}' not found.")
|
|
561
|
-
get_resp.raise_for_status()
|
|
562
|
-
get_data = get_resp.json()
|
|
563
|
-
if isinstance(get_data, dict):
|
|
564
|
-
_fields_raw = get_data.get("fields")
|
|
565
|
-
get_fields = _fields_raw if isinstance(_fields_raw, dict) else {}
|
|
566
|
-
if get_fields.get(epic_field) == parent_id:
|
|
567
|
-
return ProviderLinkResult(
|
|
568
|
-
source_id=parent_id,
|
|
569
|
-
target_id=child_id,
|
|
570
|
-
status="already-linked",
|
|
571
|
-
)
|
|
572
|
-
parent_field_val = get_fields.get("parent")
|
|
573
|
-
if isinstance(parent_field_val, dict) and parent_field_val.get("key") == parent_id:
|
|
574
|
-
return ProviderLinkResult(
|
|
575
|
-
source_id=parent_id,
|
|
576
|
-
target_id=child_id,
|
|
577
|
-
status="already-linked",
|
|
578
|
-
)
|
|
553
|
+
# Idempotency check via find_existing_link (FR-003)
|
|
554
|
+
existing = self.find_existing_link(parent_id, child_id)
|
|
555
|
+
if existing is not None:
|
|
556
|
+
return existing
|
|
579
557
|
|
|
558
|
+
epic_field = self._get_epic_link_field()
|
|
580
559
|
url = self._api_url(f"/issue/{child_id}")
|
|
581
560
|
payload = {"fields": {epic_field: parent_id}}
|
|
582
561
|
epic_resp = self._request("PUT", url, json=payload)
|
|
@@ -646,35 +625,10 @@ class JiraProvider:
|
|
|
646
625
|
)
|
|
647
626
|
|
|
648
627
|
url = self._api_url("/issueLink")
|
|
649
|
-
#
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
raise ValueError(f"Cannot check existing links because issue '{issue_id}' was not found.")
|
|
654
|
-
get_resp.raise_for_status()
|
|
655
|
-
get_data = get_resp.json()
|
|
656
|
-
if isinstance(get_data, dict):
|
|
657
|
-
_fields_raw = get_data.get("fields")
|
|
658
|
-
fields = _fields_raw if isinstance(_fields_raw, dict) else {}
|
|
659
|
-
existing_links = fields.get("issuelinks") or []
|
|
660
|
-
for link in existing_links:
|
|
661
|
-
if not isinstance(link, dict):
|
|
662
|
-
continue
|
|
663
|
-
link_type = link.get("type")
|
|
664
|
-
if not isinstance(link_type, dict):
|
|
665
|
-
continue
|
|
666
|
-
if (link_type.get("name") or "").lower() == "blocks":
|
|
667
|
-
_raw_outward = link.get("outwardIssue")
|
|
668
|
-
outward = _raw_outward if isinstance(_raw_outward, dict) else {}
|
|
669
|
-
# Only the outward direction means "blocked_by_id blocks issue_id".
|
|
670
|
-
# inwardIssue=blocked_by_id would mean the opposite edge (issue_id
|
|
671
|
-
# blocks blocked_by_id) and must NOT short-circuit idempotency.
|
|
672
|
-
if outward.get("key") == blocked_by_id:
|
|
673
|
-
return ProviderLinkResult(
|
|
674
|
-
source_id=blocked_by_id,
|
|
675
|
-
target_id=issue_id,
|
|
676
|
-
status="already-linked",
|
|
677
|
-
)
|
|
628
|
+
# Idempotency check via find_existing_dependency (FR-004)
|
|
629
|
+
existing = self.find_existing_dependency(issue_id, blocked_by_id)
|
|
630
|
+
if existing is not None:
|
|
631
|
+
return existing
|
|
678
632
|
|
|
679
633
|
payload = {
|
|
680
634
|
"type": {"name": "Blocks"},
|
|
@@ -811,29 +765,140 @@ class JiraProvider:
|
|
|
811
765
|
return self._effective_type_map[type_lower]
|
|
812
766
|
|
|
813
767
|
def _find_by_orchestration_key(self, orch_key: str) -> ProviderIssueResult | None:
|
|
814
|
-
"""Search for an existing issue via JQL with orchestration key.
|
|
815
|
-
|
|
768
|
+
"""Search for an existing issue via JQL with orchestration key.
|
|
769
|
+
|
|
770
|
+
Internal helper that delegates to :meth:`find_existing_issue`.
|
|
771
|
+
"""
|
|
772
|
+
return self.find_existing_issue(orch_key)
|
|
773
|
+
|
|
774
|
+
# ------------------------------------------------------------------
|
|
775
|
+
# IdempotencyQueryProvider methods
|
|
776
|
+
# ------------------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
def find_existing_issue(self, orchestration_key: str) -> ProviderIssueResult | None:
|
|
779
|
+
"""Find an issue by its embedded orchestration key via JQL search.
|
|
780
|
+
|
|
781
|
+
Raises ValueError if multiple issues match (ambiguous state — FR-008).
|
|
782
|
+
Propagates network/provider errors (FR-009).
|
|
783
|
+
"""
|
|
784
|
+
jql = f'project = "{self._project_key}" AND description ~ "agdt-orch-key:{orchestration_key}"'
|
|
816
785
|
url = self._api_url("/search")
|
|
817
|
-
params = {"jql": jql, "maxResults": "
|
|
786
|
+
params = {"jql": jql, "maxResults": "10", "fields": "key,summary"}
|
|
818
787
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
788
|
+
resp = self._request("GET", url, params=params)
|
|
789
|
+
resp.raise_for_status()
|
|
790
|
+
data = resp.json()
|
|
791
|
+
if not isinstance(data, dict):
|
|
792
|
+
raise ValueError(f"find_existing_issue: expected a JSON object from Jira search, got {type(data).__name__}")
|
|
793
|
+
issues = data.get("issues", [])
|
|
794
|
+
if not isinstance(issues, list):
|
|
795
|
+
raise ValueError(f"find_existing_issue: expected 'issues' to be a list, got {type(issues).__name__}")
|
|
796
|
+
if len(issues) > 1:
|
|
797
|
+
ids = [issue.get("key", "") if isinstance(issue, dict) else repr(issue) for issue in issues]
|
|
798
|
+
raise ValueError(
|
|
799
|
+
f"Ambiguous state: orchestration key {orchestration_key!r} "
|
|
800
|
+
f"matched {len(issues)} issues: {ids}. "
|
|
801
|
+
f"Resolve manually before re-running."
|
|
802
|
+
)
|
|
803
|
+
if issues:
|
|
804
|
+
issue = issues[0]
|
|
805
|
+
if not isinstance(issue, dict):
|
|
806
|
+
raise ValueError(
|
|
807
|
+
f"find_existing_issue: expected issue entry to be a JSON object, got {type(issue).__name__}"
|
|
808
|
+
)
|
|
809
|
+
issue_key = issue.get("key", "")
|
|
810
|
+
if not isinstance(issue_key, str) or not issue_key.strip():
|
|
811
|
+
raise ValueError(
|
|
812
|
+
f"find_existing_issue: expected issue 'key' to be a non-empty string, got {issue_key!r}"
|
|
832
813
|
)
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
814
|
+
return ProviderIssueResult(
|
|
815
|
+
identifier=issue_key,
|
|
816
|
+
url=f"{self._base_url}/browse/{issue_key}",
|
|
817
|
+
status="existing",
|
|
818
|
+
metadata={"id": issue.get("id", "")},
|
|
819
|
+
)
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
def find_existing_link(self, parent_provider_id: str, child_provider_id: str) -> ProviderLinkResult | None:
|
|
823
|
+
"""Check if a parent-child link already exists (FR-003).
|
|
824
|
+
|
|
825
|
+
GETs the child issue and inspects epic-link and parent fields.
|
|
826
|
+
Returns ProviderLinkResult with status="already-linked" if found,
|
|
827
|
+
None if not found. Propagates network errors (FR-009).
|
|
828
|
+
"""
|
|
829
|
+
epic_field = self._get_epic_link_field()
|
|
830
|
+
get_url = self._api_url(f"/issue/{child_provider_id}?fields={epic_field},parent")
|
|
831
|
+
get_resp = self._request("GET", get_url)
|
|
832
|
+
if get_resp.status_code == 404:
|
|
833
|
+
return None
|
|
834
|
+
get_resp.raise_for_status()
|
|
835
|
+
get_data = get_resp.json()
|
|
836
|
+
if not isinstance(get_data, dict):
|
|
837
|
+
raise ValueError(f"find_existing_link: expected a JSON object from Jira, got {type(get_data).__name__}")
|
|
838
|
+
_fields_raw = get_data.get("fields")
|
|
839
|
+
get_fields = _fields_raw if isinstance(_fields_raw, dict) else {}
|
|
840
|
+
if get_fields.get(epic_field) == parent_provider_id:
|
|
841
|
+
return ProviderLinkResult(
|
|
842
|
+
source_id=parent_provider_id,
|
|
843
|
+
target_id=child_provider_id,
|
|
844
|
+
status="already-linked",
|
|
845
|
+
)
|
|
846
|
+
parent_field_val = get_fields.get("parent")
|
|
847
|
+
if isinstance(parent_field_val, dict) and parent_field_val.get("key") == parent_provider_id:
|
|
848
|
+
return ProviderLinkResult(
|
|
849
|
+
source_id=parent_provider_id,
|
|
850
|
+
target_id=child_provider_id,
|
|
851
|
+
status="already-linked",
|
|
852
|
+
)
|
|
853
|
+
return None
|
|
854
|
+
|
|
855
|
+
def find_existing_dependency(
|
|
856
|
+
self, issue_provider_id: str, blocked_by_provider_id: str
|
|
857
|
+
) -> ProviderLinkResult | None:
|
|
858
|
+
"""Check if a blocking dependency already exists (FR-004).
|
|
859
|
+
|
|
860
|
+
GETs the issue's links and checks for a "Blocks" outward link
|
|
861
|
+
to blocked_by_provider_id. Returns ProviderLinkResult with
|
|
862
|
+
status="already-linked" if found, None if not found.
|
|
863
|
+
Propagates network errors (FR-009).
|
|
864
|
+
"""
|
|
865
|
+
get_url = self._api_url(f"/issue/{issue_provider_id}?fields=issuelinks")
|
|
866
|
+
get_resp = self._request("GET", get_url)
|
|
867
|
+
if get_resp.status_code == 404:
|
|
868
|
+
return None
|
|
869
|
+
get_resp.raise_for_status()
|
|
870
|
+
get_data = get_resp.json()
|
|
871
|
+
if not isinstance(get_data, dict):
|
|
872
|
+
raise ValueError(
|
|
873
|
+
f"find_existing_dependency: expected a JSON object from Jira, got {type(get_data).__name__}"
|
|
874
|
+
)
|
|
875
|
+
_fields_raw = get_data.get("fields")
|
|
876
|
+
fields = _fields_raw if isinstance(_fields_raw, dict) else {}
|
|
877
|
+
existing_links_raw = fields.get("issuelinks")
|
|
878
|
+
if existing_links_raw is None:
|
|
879
|
+
existing_links: list[Any] = []
|
|
880
|
+
elif isinstance(existing_links_raw, list):
|
|
881
|
+
existing_links = existing_links_raw
|
|
882
|
+
else:
|
|
883
|
+
raise ValueError(
|
|
884
|
+
"find_existing_dependency: expected fields.issuelinks to be a list when present, "
|
|
885
|
+
f"got {type(existing_links_raw).__name__}"
|
|
886
|
+
)
|
|
887
|
+
for link in existing_links:
|
|
888
|
+
if not isinstance(link, dict):
|
|
889
|
+
continue
|
|
890
|
+
link_type = link.get("type")
|
|
891
|
+
if not isinstance(link_type, dict):
|
|
892
|
+
continue
|
|
893
|
+
if (link_type.get("name") or "").lower() == "blocks":
|
|
894
|
+
_raw_outward = link.get("outwardIssue")
|
|
895
|
+
outward = _raw_outward if isinstance(_raw_outward, dict) else {}
|
|
896
|
+
if outward.get("key") == blocked_by_provider_id:
|
|
897
|
+
return ProviderLinkResult(
|
|
898
|
+
source_id=blocked_by_provider_id,
|
|
899
|
+
target_id=issue_provider_id,
|
|
900
|
+
status="already-linked",
|
|
901
|
+
)
|
|
837
902
|
return None
|
|
838
903
|
|
|
839
904
|
# ------------------------------------------------------------------
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Operation plan data model for dry-run and execution tracking (FR-006).
|
|
2
|
+
|
|
3
|
+
Defines ``OperationDescriptor`` (a single planned/executed operation) and
|
|
4
|
+
``OperationPlan`` (an ordered collection of descriptors). Both are frozen
|
|
5
|
+
dataclasses — strictly in-memory, not designed for disk persistence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentic_devtools.adapters.issue_provider import ProviderIssueResult, ProviderLinkResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class OperationDescriptor:
|
|
19
|
+
"""A single planned or executed operation.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
operation_type: One of "create_issue", "link_subissue", or "add_blocked_by".
|
|
23
|
+
orchestration_key: 64-char hex SHA-256 key for this operation.
|
|
24
|
+
refs: Manifest refs used in key derivation, in canonical order.
|
|
25
|
+
status: Operation outcome — "dry-run", "existing", "already-linked",
|
|
26
|
+
"created", or "linked".
|
|
27
|
+
provider_params: Provider-facing operation inputs captured in the
|
|
28
|
+
plan (for example title/issue_type/labels, plus ref-based
|
|
29
|
+
relationship fields such as parent_ref/child_ref/blocked_by_ref
|
|
30
|
+
used during planning before provider identifiers are resolved).
|
|
31
|
+
result: The adapter result object, or None for planning-only dry-run.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
operation_type: str
|
|
35
|
+
orchestration_key: str
|
|
36
|
+
refs: tuple[str, ...]
|
|
37
|
+
status: str
|
|
38
|
+
provider_params: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
result: ProviderIssueResult | ProviderLinkResult | None = None
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
"""Return a JSON-serializable dictionary representation."""
|
|
43
|
+
result_dict: dict[str, Any] | None = None
|
|
44
|
+
if self.result is not None:
|
|
45
|
+
result_dict = self.result.to_dict()
|
|
46
|
+
return {
|
|
47
|
+
"operation_type": self.operation_type,
|
|
48
|
+
"orchestration_key": self.orchestration_key,
|
|
49
|
+
"refs": list(self.refs),
|
|
50
|
+
"status": self.status,
|
|
51
|
+
"provider_params": copy.deepcopy(self.provider_params),
|
|
52
|
+
"result": result_dict,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_dry_run(self) -> bool:
|
|
57
|
+
"""Return True if this descriptor represents a dry-run operation."""
|
|
58
|
+
return self.status == "dry-run"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_existing(self) -> bool:
|
|
62
|
+
"""Return True if this descriptor represents an already-existing entity."""
|
|
63
|
+
return self.status in ("existing", "already-linked")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class OperationPlan:
|
|
68
|
+
"""An ordered collection of operation descriptors.
|
|
69
|
+
|
|
70
|
+
Strictly in-memory — not designed for serialization to disk for
|
|
71
|
+
cross-run resumption. The orchestrator re-derives the plan from the
|
|
72
|
+
manifest on each invocation.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
operations: Dependency-safe ordered tuple of descriptors.
|
|
76
|
+
dry_run: Whether this plan was generated in dry-run mode.
|
|
77
|
+
check_existing: Whether existence checks were performed.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
operations: tuple[OperationDescriptor, ...]
|
|
81
|
+
dry_run: bool
|
|
82
|
+
check_existing: bool
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict[str, Any]:
|
|
85
|
+
"""Return a JSON-serializable dictionary representation."""
|
|
86
|
+
return {
|
|
87
|
+
"operations": [op.to_dict() for op in self.operations],
|
|
88
|
+
"dry_run": self.dry_run,
|
|
89
|
+
"check_existing": self.check_existing,
|
|
90
|
+
"summary": {
|
|
91
|
+
"total": len(self.operations),
|
|
92
|
+
"creates": len(self.create_operations),
|
|
93
|
+
"links": len(self.link_operations),
|
|
94
|
+
"dependencies": len(self.dependency_operations),
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def create_operations(self) -> tuple[OperationDescriptor, ...]:
|
|
100
|
+
"""Return only create_issue operations."""
|
|
101
|
+
return tuple(op for op in self.operations if op.operation_type == "create_issue")
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def link_operations(self) -> tuple[OperationDescriptor, ...]:
|
|
105
|
+
"""Return only link_subissue operations."""
|
|
106
|
+
return tuple(op for op in self.operations if op.operation_type == "link_subissue")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def dependency_operations(self) -> tuple[OperationDescriptor, ...]:
|
|
110
|
+
"""Return only add_blocked_by operations."""
|
|
111
|
+
return tuple(op for op in self.operations if op.operation_type == "add_blocked_by")
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
"""Orchestration key generation and embedding utilities.
|
|
2
2
|
|
|
3
|
-
An orchestration key is a SHA-256 hash derived from:
|
|
4
|
-
-
|
|
5
|
-
-
|
|
6
|
-
|
|
3
|
+
An orchestration key is a deterministic SHA-256 hash derived from:
|
|
4
|
+
- operation_type: The operation name (e.g., "create_issue", "link_subissue")
|
|
5
|
+
- refs: One or more ref values in canonical order matching the IssueProvider
|
|
6
|
+
method signature for the given operation
|
|
7
|
+
|
|
8
|
+
Algorithm: SHA-256 of the UTF-8 encoding of the NUL-separated (``\\x00``)
|
|
9
|
+
concatenation of ``operation_type`` followed by all ref values in signature order.
|
|
7
10
|
|
|
8
11
|
The key is embedded in issue bodies as an HTML comment:
|
|
9
12
|
``<!-- agdt-orch-key:SHA256_HEX -->``
|
|
10
13
|
|
|
11
14
|
This enables idempotent find-before-create semantics — the provider can search
|
|
12
15
|
for an existing issue bearing the same orchestration key before creating a new one.
|
|
16
|
+
|
|
17
|
+
Compatibility: The pre-#2114 algorithm (colon-separated ``source:position:content_hash``)
|
|
18
|
+
is explicitly **not** backward-compatible. Orchestration keys generated with the prior
|
|
19
|
+
algorithm will not match keys from this implementation. The embedding format
|
|
20
|
+
(``<!-- agdt-orch-key:<64hex> -->``) remains unchanged (NFR-002).
|
|
13
21
|
"""
|
|
14
22
|
|
|
15
23
|
from __future__ import annotations
|
|
@@ -21,22 +29,38 @@ import re
|
|
|
21
29
|
_ORCH_KEY_PATTERN = re.compile(r"<!-- agdt-orch-key:([a-f0-9]{64}) -->", re.IGNORECASE)
|
|
22
30
|
|
|
23
31
|
|
|
24
|
-
def generate_orchestration_key(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"""Generate a SHA-256 orchestration key.
|
|
32
|
+
def generate_orchestration_key(operation_type: str, *refs: str) -> str:
|
|
33
|
+
"""Generate a deterministic SHA-256 orchestration key.
|
|
34
|
+
|
|
35
|
+
Algorithm: SHA-256 of UTF-8 encoding of the NUL-separated concatenation
|
|
36
|
+
of operation_type followed by all ref values in signature order.
|
|
30
37
|
|
|
31
38
|
Args:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
operation_type: The operation name (e.g., "create_issue").
|
|
40
|
+
*refs: Ref values in the canonical order matching the IssueProvider
|
|
41
|
+
method signature for this operation.
|
|
35
42
|
|
|
36
43
|
Returns:
|
|
37
|
-
A 64-character lowercase hex SHA-256
|
|
44
|
+
A 64-character lowercase hex SHA-256 digest.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If operation_type is not a string, empty/whitespace-only,
|
|
48
|
+
or if operation_type or any ref contains a NUL byte (``\\x00``).
|
|
49
|
+
If any ref is not a string.
|
|
38
50
|
"""
|
|
39
|
-
|
|
51
|
+
if not isinstance(operation_type, str):
|
|
52
|
+
raise ValueError(f"operation_type must be a string, got {type(operation_type).__name__}")
|
|
53
|
+
if not operation_type or not operation_type.strip():
|
|
54
|
+
raise ValueError("operation_type must be a non-empty string")
|
|
55
|
+
if "\x00" in operation_type:
|
|
56
|
+
raise ValueError("operation_type must not contain NUL bytes")
|
|
57
|
+
for ref in refs:
|
|
58
|
+
if not isinstance(ref, str):
|
|
59
|
+
raise ValueError(f"each ref must be a string, got {type(ref).__name__}")
|
|
60
|
+
if "\x00" in ref:
|
|
61
|
+
raise ValueError("refs must not contain NUL bytes")
|
|
62
|
+
|
|
63
|
+
payload = "\x00".join((operation_type, *refs))
|
|
40
64
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
41
65
|
|
|
42
66
|
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Manifest planning and execution for dry-run and idempotent issue creation.
|
|
2
|
+
|
|
3
|
+
Provides ``plan_manifest()`` for assembling a dependency-safe ``OperationPlan``
|
|
4
|
+
from a JSON manifest, and ``execute_manifest()`` as a convenience wrapper for
|
|
5
|
+
real execution mode.
|
|
6
|
+
|
|
7
|
+
The manifest describes a tree of issues with parent-child and blocking
|
|
8
|
+
relationships. The orchestrator traverses the manifest in topological order,
|
|
9
|
+
computes orchestration keys for each operation, and either previews (dry-run)
|
|
10
|
+
or executes (real) the operations through an ``IssueProvider``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from agentic_devtools.adapters.cycle_detection import CycleDetectedError, detect_cycles
|
|
18
|
+
from agentic_devtools.adapters.idempotency_query_provider import IdempotencyQueryProvider
|
|
19
|
+
from agentic_devtools.adapters.issue_provider import VALID_ISSUE_TYPES, IssueProvider
|
|
20
|
+
from agentic_devtools.adapters.operation_plan import OperationDescriptor, OperationPlan
|
|
21
|
+
from agentic_devtools.adapters.orchestration_key import embed_orchestration_key, generate_orchestration_key
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def plan_manifest(
|
|
25
|
+
manifest: dict[str, Any],
|
|
26
|
+
provider: IssueProvider,
|
|
27
|
+
*,
|
|
28
|
+
dry_run: bool = True,
|
|
29
|
+
check_existing: bool = False,
|
|
30
|
+
query_provider: IdempotencyQueryProvider | None = None,
|
|
31
|
+
) -> OperationPlan:
|
|
32
|
+
"""Assemble a dependency-safe operation plan from a manifest.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
manifest: JSON manifest with "nodes" list. Each node has "ref",
|
|
36
|
+
"title", "body", "issue_type", and optionally "parent_ref"
|
|
37
|
+
and "blocked_by" (list of refs).
|
|
38
|
+
provider: IssueProvider for dry-run param extraction / real execution.
|
|
39
|
+
dry_run: When True, no mutations. When False, execute operations.
|
|
40
|
+
check_existing: When True (dry-run only), query provider for
|
|
41
|
+
existing entities. Requires query_provider.
|
|
42
|
+
query_provider: IdempotencyQueryProvider for existence checks.
|
|
43
|
+
Required when check_existing=True.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
OperationPlan with ordered OperationDescriptor entries.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If check_existing=True but query_provider is None.
|
|
50
|
+
ValueError: If check_existing=True but dry_run=False.
|
|
51
|
+
ValueError: If check_existing=True but query_provider does not implement
|
|
52
|
+
the IdempotencyQueryProvider protocol.
|
|
53
|
+
ValueError: If manifest is not a dict, manifest['nodes'] is not a list,
|
|
54
|
+
any node is not a dict, a node is missing a required key
|
|
55
|
+
('ref', 'title', 'issue_type'), a required key value is not a
|
|
56
|
+
non-empty string after trimming, issue_type is unsupported,
|
|
57
|
+
'body' is present but is neither a string nor null,
|
|
58
|
+
'blocked_by' or 'labels' is present but not a list,
|
|
59
|
+
or duplicate refs are detected.
|
|
60
|
+
ValueError: If a circular dependency is detected among manifest nodes.
|
|
61
|
+
ValueError: If a node's parent_ref or blocked_by ref cannot be resolved during real execution.
|
|
62
|
+
Any provider error: Propagated from adapter calls (FR-009).
|
|
63
|
+
"""
|
|
64
|
+
if check_existing and query_provider is None:
|
|
65
|
+
raise ValueError("check_existing=True requires a query_provider")
|
|
66
|
+
if check_existing and not dry_run:
|
|
67
|
+
raise ValueError("check_existing=True is only valid with dry_run=True")
|
|
68
|
+
if check_existing and not isinstance(query_provider, IdempotencyQueryProvider):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"query_provider must implement the IdempotencyQueryProvider protocol, got {type(query_provider).__name__}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if not isinstance(manifest, dict):
|
|
74
|
+
raise ValueError(f"manifest must be a dict, got {type(manifest).__name__}")
|
|
75
|
+
|
|
76
|
+
nodes = manifest.get("nodes", [])
|
|
77
|
+
|
|
78
|
+
# Validate manifest structure up front for deterministic, actionable errors.
|
|
79
|
+
if not isinstance(nodes, list):
|
|
80
|
+
raise ValueError(f"manifest['nodes'] must be a list, got {type(nodes).__name__}")
|
|
81
|
+
validated_nodes: list[dict[str, Any]] = []
|
|
82
|
+
seen_refs: set[str] = set()
|
|
83
|
+
for i, node in enumerate(nodes):
|
|
84
|
+
if not isinstance(node, dict):
|
|
85
|
+
raise ValueError(f"manifest['nodes'][{i}] must be a dict, got {type(node).__name__}")
|
|
86
|
+
normalized_node = dict(node)
|
|
87
|
+
for key in ("ref", "title", "issue_type"):
|
|
88
|
+
if key not in node:
|
|
89
|
+
raise ValueError(f"manifest['nodes'][{i}] missing required key {key!r}")
|
|
90
|
+
value = node[key]
|
|
91
|
+
if not isinstance(value, str) or not value.strip():
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"manifest['nodes'][{i}][{key!r}] must be a non-empty string, got {type(value).__name__!r}"
|
|
94
|
+
)
|
|
95
|
+
if key == "issue_type":
|
|
96
|
+
normalized_value = value.strip().lower()
|
|
97
|
+
if normalized_value not in VALID_ISSUE_TYPES:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Unsupported issue_type {normalized_value!r}. Valid types: {sorted(VALID_ISSUE_TYPES)}"
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
normalized_value = value.strip()
|
|
103
|
+
normalized_node[key] = normalized_value
|
|
104
|
+
if "body" in node and node["body"] is not None and not isinstance(node["body"], str):
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"manifest['nodes'][{i}]['body'] must be a string or null, got {type(node['body']).__name__!r}"
|
|
107
|
+
)
|
|
108
|
+
if "parent_ref" in node:
|
|
109
|
+
parent_ref_val = node["parent_ref"]
|
|
110
|
+
if not isinstance(parent_ref_val, str) or not parent_ref_val.strip():
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"manifest['nodes'][{i}]['parent_ref'] must be a non-empty string, "
|
|
113
|
+
f"got {type(parent_ref_val).__name__!r}"
|
|
114
|
+
)
|
|
115
|
+
normalized_node["parent_ref"] = parent_ref_val.strip()
|
|
116
|
+
for list_key in ("blocked_by", "labels"):
|
|
117
|
+
if list_key not in node:
|
|
118
|
+
continue
|
|
119
|
+
value = node[list_key]
|
|
120
|
+
if not isinstance(value, list):
|
|
121
|
+
raise ValueError(f"manifest['nodes'][{i}][{list_key!r}] must be a list, got {type(value).__name__!r}")
|
|
122
|
+
normalized_elems: list[str] = []
|
|
123
|
+
for j, elem in enumerate(value):
|
|
124
|
+
if not isinstance(elem, str) or not elem.strip():
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"manifest['nodes'][{i}][{list_key!r}][{j}] must be a non-empty string, "
|
|
127
|
+
f"got {type(elem).__name__!r}"
|
|
128
|
+
)
|
|
129
|
+
normalized_elems.append(elem.strip())
|
|
130
|
+
normalized_node[list_key] = normalized_elems
|
|
131
|
+
node_ref = normalized_node["ref"]
|
|
132
|
+
if node_ref in seen_refs:
|
|
133
|
+
raise ValueError(f"Duplicate ref {node_ref!r} in manifest['nodes']")
|
|
134
|
+
seen_refs.add(node_ref)
|
|
135
|
+
validated_nodes.append(normalized_node)
|
|
136
|
+
|
|
137
|
+
# Build node lookup and edges for topological sort
|
|
138
|
+
node_map: dict[str, dict[str, Any]] = {}
|
|
139
|
+
for node in validated_nodes:
|
|
140
|
+
node_map[node["ref"]] = node
|
|
141
|
+
|
|
142
|
+
# Determine topological order based on blocked_by relationships
|
|
143
|
+
edges: list[tuple[str, str]] = []
|
|
144
|
+
for node in validated_nodes:
|
|
145
|
+
for blocker_ref in node.get("blocked_by", []):
|
|
146
|
+
# blocker must be created before this node
|
|
147
|
+
edges.append((blocker_ref, node["ref"]))
|
|
148
|
+
|
|
149
|
+
# Also ensure parents are created before children
|
|
150
|
+
for node in validated_nodes:
|
|
151
|
+
parent_ref = node.get("parent_ref")
|
|
152
|
+
if parent_ref:
|
|
153
|
+
edges.append((parent_ref, node["ref"]))
|
|
154
|
+
|
|
155
|
+
if edges:
|
|
156
|
+
try:
|
|
157
|
+
sorted_refs = detect_cycles(edges)
|
|
158
|
+
except CycleDetectedError as exc:
|
|
159
|
+
raise ValueError(str(exc)) from exc
|
|
160
|
+
# Include any nodes not in the edge graph (isolated nodes)
|
|
161
|
+
sorted_refs_set = set(sorted_refs)
|
|
162
|
+
remaining = [r for r in node_map if r not in sorted_refs_set]
|
|
163
|
+
sorted_refs = sorted_refs + remaining
|
|
164
|
+
else:
|
|
165
|
+
sorted_refs = list(node_map.keys())
|
|
166
|
+
|
|
167
|
+
# In real execution, validate all cross-node references before any mutation.
|
|
168
|
+
if not dry_run:
|
|
169
|
+
for ref in sorted_refs:
|
|
170
|
+
if ref not in node_map:
|
|
171
|
+
continue
|
|
172
|
+
node = node_map[ref]
|
|
173
|
+
parent_ref = node.get("parent_ref")
|
|
174
|
+
if parent_ref and parent_ref not in node_map:
|
|
175
|
+
raise ValueError(f"parent_ref {parent_ref!r} for manifest ref {ref!r} could not be resolved")
|
|
176
|
+
for blocker_ref in node.get("blocked_by", []):
|
|
177
|
+
if blocker_ref not in node_map:
|
|
178
|
+
raise ValueError(f"blocked_by ref {blocker_ref!r} for manifest ref {ref!r} could not be resolved")
|
|
179
|
+
|
|
180
|
+
# Phase 1: Generate create operations in topological order
|
|
181
|
+
descriptors: list[OperationDescriptor] = []
|
|
182
|
+
ref_bindings: dict[str, str] = {} # manifest ref → provider identifier
|
|
183
|
+
|
|
184
|
+
for ref in sorted_refs:
|
|
185
|
+
if ref not in node_map:
|
|
186
|
+
continue
|
|
187
|
+
node = node_map[ref]
|
|
188
|
+
orch_key = generate_orchestration_key("create_issue", ref)
|
|
189
|
+
body = node.get("body", "")
|
|
190
|
+
if body is None:
|
|
191
|
+
body = ""
|
|
192
|
+
body_with_key = embed_orchestration_key(body, orch_key)
|
|
193
|
+
|
|
194
|
+
provider_params: dict[str, Any] = {
|
|
195
|
+
"title": node["title"],
|
|
196
|
+
"body": body_with_key,
|
|
197
|
+
"issue_type": node["issue_type"],
|
|
198
|
+
}
|
|
199
|
+
if node.get("parent_ref"):
|
|
200
|
+
provider_params["parent_ref"] = node["parent_ref"]
|
|
201
|
+
if node.get("labels"):
|
|
202
|
+
provider_params["labels"] = node["labels"]
|
|
203
|
+
|
|
204
|
+
if dry_run and not check_existing:
|
|
205
|
+
# Mode 1: planning-only dry-run — assemble descriptors only.
|
|
206
|
+
descriptors.append(
|
|
207
|
+
OperationDescriptor(
|
|
208
|
+
operation_type="create_issue",
|
|
209
|
+
orchestration_key=orch_key,
|
|
210
|
+
refs=(ref,),
|
|
211
|
+
status="dry-run",
|
|
212
|
+
provider_params=provider_params,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
elif dry_run and check_existing:
|
|
216
|
+
# Mode 2: dry-run with existence checks
|
|
217
|
+
assert query_provider is not None
|
|
218
|
+
existing = query_provider.find_existing_issue(orch_key)
|
|
219
|
+
if existing is not None:
|
|
220
|
+
ref_bindings[ref] = existing.identifier
|
|
221
|
+
descriptors.append(
|
|
222
|
+
OperationDescriptor(
|
|
223
|
+
operation_type="create_issue",
|
|
224
|
+
orchestration_key=orch_key,
|
|
225
|
+
refs=(ref,),
|
|
226
|
+
status="existing",
|
|
227
|
+
provider_params=provider_params,
|
|
228
|
+
result=existing,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
descriptors.append(
|
|
233
|
+
OperationDescriptor(
|
|
234
|
+
operation_type="create_issue",
|
|
235
|
+
orchestration_key=orch_key,
|
|
236
|
+
refs=(ref,),
|
|
237
|
+
status="dry-run",
|
|
238
|
+
provider_params=provider_params,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
# Mode 3: real execution
|
|
243
|
+
parent_id: str | None = None
|
|
244
|
+
if node.get("parent_ref"):
|
|
245
|
+
parent_id = ref_bindings[node["parent_ref"]]
|
|
246
|
+
|
|
247
|
+
result = provider.create_issue(
|
|
248
|
+
title=node["title"],
|
|
249
|
+
body=body_with_key,
|
|
250
|
+
issue_type=node["issue_type"],
|
|
251
|
+
parent_id=parent_id,
|
|
252
|
+
labels=node.get("labels"),
|
|
253
|
+
idempotency_key=orch_key,
|
|
254
|
+
dry_run=False,
|
|
255
|
+
)
|
|
256
|
+
ref_bindings[ref] = result.identifier
|
|
257
|
+
descriptors.append(
|
|
258
|
+
OperationDescriptor(
|
|
259
|
+
operation_type="create_issue",
|
|
260
|
+
orchestration_key=orch_key,
|
|
261
|
+
refs=(ref,),
|
|
262
|
+
status=result.status,
|
|
263
|
+
provider_params=provider_params,
|
|
264
|
+
result=result,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Phase 2: Generate link_subissue operations
|
|
269
|
+
for ref in sorted_refs:
|
|
270
|
+
if ref not in node_map:
|
|
271
|
+
continue
|
|
272
|
+
node = node_map[ref]
|
|
273
|
+
parent_ref = node.get("parent_ref")
|
|
274
|
+
if not parent_ref:
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
orch_key = generate_orchestration_key("link_subissue", parent_ref, ref)
|
|
278
|
+
provider_params = {"parent_ref": parent_ref, "child_ref": ref}
|
|
279
|
+
|
|
280
|
+
if dry_run and not check_existing:
|
|
281
|
+
descriptors.append(
|
|
282
|
+
OperationDescriptor(
|
|
283
|
+
operation_type="link_subissue",
|
|
284
|
+
orchestration_key=orch_key,
|
|
285
|
+
refs=(parent_ref, ref),
|
|
286
|
+
status="dry-run",
|
|
287
|
+
provider_params=provider_params,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
elif dry_run and check_existing:
|
|
291
|
+
assert query_provider is not None
|
|
292
|
+
parent_id = ref_bindings.get(parent_ref)
|
|
293
|
+
child_id = ref_bindings.get(ref)
|
|
294
|
+
if parent_id and child_id:
|
|
295
|
+
link_existing = query_provider.find_existing_link(parent_id, child_id)
|
|
296
|
+
if link_existing is not None:
|
|
297
|
+
descriptors.append(
|
|
298
|
+
OperationDescriptor(
|
|
299
|
+
operation_type="link_subissue",
|
|
300
|
+
orchestration_key=orch_key,
|
|
301
|
+
refs=(parent_ref, ref),
|
|
302
|
+
status="already-linked",
|
|
303
|
+
provider_params=provider_params,
|
|
304
|
+
result=link_existing,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
else:
|
|
308
|
+
descriptors.append(
|
|
309
|
+
OperationDescriptor(
|
|
310
|
+
operation_type="link_subissue",
|
|
311
|
+
orchestration_key=orch_key,
|
|
312
|
+
refs=(parent_ref, ref),
|
|
313
|
+
status="dry-run",
|
|
314
|
+
provider_params=provider_params,
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
descriptors.append(
|
|
319
|
+
OperationDescriptor(
|
|
320
|
+
operation_type="link_subissue",
|
|
321
|
+
orchestration_key=orch_key,
|
|
322
|
+
refs=(parent_ref, ref),
|
|
323
|
+
status="dry-run",
|
|
324
|
+
provider_params=provider_params,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
# Real execution
|
|
329
|
+
parent_id = ref_bindings[parent_ref]
|
|
330
|
+
child_id = ref_bindings[ref]
|
|
331
|
+
link_result = provider.link_subissue(parent_id, child_id, dry_run=False)
|
|
332
|
+
descriptors.append(
|
|
333
|
+
OperationDescriptor(
|
|
334
|
+
operation_type="link_subissue",
|
|
335
|
+
orchestration_key=orch_key,
|
|
336
|
+
refs=(parent_ref, ref),
|
|
337
|
+
status=link_result.status,
|
|
338
|
+
provider_params=provider_params,
|
|
339
|
+
result=link_result,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Phase 3: Generate add_blocked_by operations
|
|
344
|
+
for ref in sorted_refs:
|
|
345
|
+
if ref not in node_map:
|
|
346
|
+
continue
|
|
347
|
+
node = node_map[ref]
|
|
348
|
+
for blocker_ref in node.get("blocked_by", []):
|
|
349
|
+
orch_key = generate_orchestration_key("add_blocked_by", ref, blocker_ref)
|
|
350
|
+
provider_params = {"issue_ref": ref, "blocked_by_ref": blocker_ref}
|
|
351
|
+
|
|
352
|
+
if dry_run and not check_existing:
|
|
353
|
+
descriptors.append(
|
|
354
|
+
OperationDescriptor(
|
|
355
|
+
operation_type="add_blocked_by",
|
|
356
|
+
orchestration_key=orch_key,
|
|
357
|
+
refs=(ref, blocker_ref),
|
|
358
|
+
status="dry-run",
|
|
359
|
+
provider_params=provider_params,
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
elif dry_run and check_existing:
|
|
363
|
+
assert query_provider is not None
|
|
364
|
+
issue_id = ref_bindings.get(ref)
|
|
365
|
+
blocker_id = ref_bindings.get(blocker_ref)
|
|
366
|
+
if issue_id and blocker_id:
|
|
367
|
+
dep_existing = query_provider.find_existing_dependency(issue_id, blocker_id)
|
|
368
|
+
if dep_existing is not None:
|
|
369
|
+
descriptors.append(
|
|
370
|
+
OperationDescriptor(
|
|
371
|
+
operation_type="add_blocked_by",
|
|
372
|
+
orchestration_key=orch_key,
|
|
373
|
+
refs=(ref, blocker_ref),
|
|
374
|
+
status="already-linked",
|
|
375
|
+
provider_params=provider_params,
|
|
376
|
+
result=dep_existing,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
descriptors.append(
|
|
381
|
+
OperationDescriptor(
|
|
382
|
+
operation_type="add_blocked_by",
|
|
383
|
+
orchestration_key=orch_key,
|
|
384
|
+
refs=(ref, blocker_ref),
|
|
385
|
+
status="dry-run",
|
|
386
|
+
provider_params=provider_params,
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
390
|
+
descriptors.append(
|
|
391
|
+
OperationDescriptor(
|
|
392
|
+
operation_type="add_blocked_by",
|
|
393
|
+
orchestration_key=orch_key,
|
|
394
|
+
refs=(ref, blocker_ref),
|
|
395
|
+
status="dry-run",
|
|
396
|
+
provider_params=provider_params,
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
# Real execution
|
|
401
|
+
issue_id = ref_bindings[ref]
|
|
402
|
+
blocker_id = ref_bindings[blocker_ref]
|
|
403
|
+
dep_result = provider.add_blocked_by(issue_id, blocker_id, dry_run=False)
|
|
404
|
+
descriptors.append(
|
|
405
|
+
OperationDescriptor(
|
|
406
|
+
operation_type="add_blocked_by",
|
|
407
|
+
orchestration_key=orch_key,
|
|
408
|
+
refs=(ref, blocker_ref),
|
|
409
|
+
status=dep_result.status,
|
|
410
|
+
provider_params=provider_params,
|
|
411
|
+
result=dep_result,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
return OperationPlan(
|
|
416
|
+
operations=tuple(descriptors),
|
|
417
|
+
dry_run=dry_run,
|
|
418
|
+
check_existing=check_existing,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def execute_manifest(
|
|
423
|
+
manifest: dict[str, Any],
|
|
424
|
+
provider: IssueProvider,
|
|
425
|
+
) -> OperationPlan:
|
|
426
|
+
"""Execute a manifest against a provider (real mutations).
|
|
427
|
+
|
|
428
|
+
Convenience wrapper for ``plan_manifest(dry_run=False)``.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
manifest: JSON manifest with nodes and edges.
|
|
432
|
+
provider: IssueProvider for real execution.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
OperationPlan with results from the executed operations.
|
|
436
|
+
"""
|
|
437
|
+
return plan_manifest(manifest, provider, dry_run=False)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
agentic_devtools/__init__.py,sha256=J_Zw_vWKghk-cLmqI83hXQmSiS8zMhGIHM5WPLDkZuo,242
|
|
2
|
-
agentic_devtools/_version.py,sha256=
|
|
2
|
+
agentic_devtools/_version.py,sha256=4iHxOBCshxxKQXkmD0BI3AmeulSZMr_G5ZVpaxUKYT0,524
|
|
3
3
|
agentic_devtools/agdt_gitignore.py,sha256=aBPBQe7M0GLH8NIp1NsyN9ZiO80fNGOi46IcT5A4SK4,1569
|
|
4
4
|
agentic_devtools/background_tasks.py,sha256=IVC1XJKQzPBP8wCRNCZX_iZCg9FJY_GBUzddSJj7xqw,17473
|
|
5
5
|
agentic_devtools/config.py,sha256=DEVxTVZhsQbVQwO9qdB_1h19aBpjMzF6xJY_ePYLGLs,15629
|
|
@@ -15,7 +15,7 @@ agentic_devtools/_bundled_skills/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRk
|
|
|
15
15
|
agentic_devtools/_bundled_skills/agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
16
|
agentic_devtools/_bundled_skills/prompts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
17
|
agentic_devtools/_bundled_skills/workflow-analysis/SKILL.md,sha256=imOFULtiGqsFEljGx0ly7_ntnZAmLWuYAF0HL82MIVA,21153
|
|
18
|
-
agentic_devtools/adapters/__init__.py,sha256=
|
|
18
|
+
agentic_devtools/adapters/__init__.py,sha256=BVj0rIs6WIPee2_hxm2yj-WSdNi-PfrHFw33GK2yJho,8161
|
|
19
19
|
agentic_devtools/adapters/base.py,sha256=7UIx89av0QOO01eqOFx9hQZC4HNhUPNe1myFBmL0NgY,6104
|
|
20
20
|
agentic_devtools/adapters/copilot-instructions.md,sha256=xkz-aVd7cc2v_tPN9CzV67O0GbKZNmkmHXSwN_ULK_8,29223
|
|
21
21
|
agentic_devtools/adapters/cycle_detection.py,sha256=93y3Yb1v2ungYeTJKxL8ztQltm8OFvsnLP67ZFVmoAQ,2350
|
|
@@ -25,12 +25,15 @@ agentic_devtools/adapters/factory.py,sha256=Z32q3wcZ196jWRlRcphRRDBqYMbTCRlz13EU
|
|
|
25
25
|
agentic_devtools/adapters/github_adapter.py,sha256=ltdJveOCHs95zzJMERDZp9T66FCYgvRR6slzKBBkJxY,22324
|
|
26
26
|
agentic_devtools/adapters/github_provider.py,sha256=6hvoaOFP-uOZQKGl2_XU8o9uR5xUzAWFFMHul3aKZuw,19992
|
|
27
27
|
agentic_devtools/adapters/github_schema.py,sha256=V793iZqst3Dj_piX6CkSbn379nvItECs7PattF95WqU,6869
|
|
28
|
-
agentic_devtools/adapters/
|
|
28
|
+
agentic_devtools/adapters/idempotency_query_provider.py,sha256=vJ9hi2bKQIQsIxhnIqFWXKV38DmS9W3T1pVRtIZ908A,2135
|
|
29
|
+
agentic_devtools/adapters/issue_provider.py,sha256=E4QNTtKC2JGZEax9cF5bCVa-FybTN0FbWlMrGzgkyt0,28775
|
|
29
30
|
agentic_devtools/adapters/issue_type_mapping.py,sha256=-Mh4fmr6Zd10CEGkDaFHVYiOJHUXFIWItAec5jmKu5c,14193
|
|
30
31
|
agentic_devtools/adapters/jira_adapter.py,sha256=Fhkdm6YaTNW6wVKf1KSXlrT18XL-qRezBD1j9TMt03k,22525
|
|
31
|
-
agentic_devtools/adapters/jira_provider.py,sha256=
|
|
32
|
+
agentic_devtools/adapters/jira_provider.py,sha256=mC-oP9-06B2xCczOXrzLbJRvk0HRzgYak7ZgYpK9wb8,37204
|
|
32
33
|
agentic_devtools/adapters/markdown_adapter.py,sha256=ej6Je7yGtj8igtHiiG_7ZbTedGyN0G1HLloLuQoWGkM,25942
|
|
33
|
-
agentic_devtools/adapters/
|
|
34
|
+
agentic_devtools/adapters/operation_plan.py,sha256=Lp46YSI3hz9UeZ253tlCzDufDZr9x4Jgk4LGwRq57Xc,4315
|
|
35
|
+
agentic_devtools/adapters/orchestration_key.py,sha256=-v2lM1HGSa6IIeeLy6vDZzxeeAN7O0ETkycuvLWRwIA,4059
|
|
36
|
+
agentic_devtools/adapters/plan_manifest.py,sha256=QozGmmQCFH5Z7HDYx3eMeI21M3PMuM_DW2qWFnQeCv0,18831
|
|
34
37
|
agentic_devtools/adapters/retry.py,sha256=CsueXIs-sLgkCVgp_hspzNnQKi_Ty-FyVN-066WncRc,3736
|
|
35
38
|
agentic_devtools/adapters/types.py,sha256=gqN2fq0i0gtMzCY1dCweZRlq3taAQNQEEHdqak5pbjI,8853
|
|
36
39
|
agentic_devtools/cli/__init__.py,sha256=VgHQrGk0NuMaGMdvooFwz3nGY1NzVi6kX5Fr7wF-Tdw,40
|
|
@@ -889,8 +892,8 @@ agentic_devtools/_bundled_skills/prompts/speckit.plan.prompt.md,sha256=IJja5r2Sd
|
|
|
889
892
|
agentic_devtools/_bundled_skills/prompts/speckit.specify.prompt.md,sha256=eyzE3GRi2hyW30a6xPYOU7q6MJf0skrD-baEGURYqpg,31
|
|
890
893
|
agentic_devtools/_bundled_skills/prompts/speckit.tasks.prompt.md,sha256=iPxXwon5nV6dNcJV8-JoP3PssKUVXctNiG-C9SsRhB8,29
|
|
891
894
|
agentic_devtools/_bundled_skills/prompts/speckit.taskstoissues.prompt.md,sha256=L5Y21PMSoUcPAAdHy2Jnf-wGVdi04jV_pPvyOJZfpm0,37
|
|
892
|
-
agentic_devtools-0.2.
|
|
893
|
-
agentic_devtools-0.2.
|
|
894
|
-
agentic_devtools-0.2.
|
|
895
|
-
agentic_devtools-0.2.
|
|
896
|
-
agentic_devtools-0.2.
|
|
895
|
+
agentic_devtools-0.2.333.dist-info/METADATA,sha256=67rnAlB09DjBFo7jRBoAQkswMf2MhNe58ciIOZephbM,29796
|
|
896
|
+
agentic_devtools-0.2.333.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
897
|
+
agentic_devtools-0.2.333.dist-info/entry_points.txt,sha256=Xs0VhRkREzs61FdX63Ed1qa1-WDeBppn7BvMdRIYYZ4,11395
|
|
898
|
+
agentic_devtools-0.2.333.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
|
|
899
|
+
agentic_devtools-0.2.333.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|