agentic-devtools 0.2.332__py3-none-any.whl → 0.2.334__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/orchestration/graph_builder.py +15 -5
- agentic_devtools/orchestration/nodes/__init__.py +31 -0
- agentic_devtools/orchestration/nodes/_helpers.py +261 -0
- agentic_devtools/orchestration/nodes/checklist_creation.py +139 -0
- agentic_devtools/orchestration/nodes/commit.py +169 -0
- agentic_devtools/orchestration/nodes/completion.py +163 -0
- agentic_devtools/orchestration/nodes/implementation.py +395 -0
- agentic_devtools/orchestration/nodes/implementation_review.py +126 -0
- agentic_devtools/orchestration/nodes/initiate.py +136 -0
- agentic_devtools/orchestration/nodes/planning.py +221 -0
- agentic_devtools/orchestration/nodes/pull_request.py +256 -0
- agentic_devtools/orchestration/nodes/retrieve.py +198 -0
- agentic_devtools/orchestration/nodes/setup.py +123 -0
- agentic_devtools/orchestration/nodes/verification.py +96 -0
- agentic_devtools/orchestration/pilot_workflow.py +53 -21
- agentic_devtools/orchestration/runner.py +62 -9
- agentic_devtools/orchestration/state_schema.py +24 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/METADATA +1 -1
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/RECORD +30 -14
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/WHEEL +0 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/entry_points.txt +0 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.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.334'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 334)
|
|
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
|
|