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.
Files changed (30) hide show
  1. agentic_devtools/_version.py +2 -2
  2. agentic_devtools/adapters/__init__.py +8 -0
  3. agentic_devtools/adapters/idempotency_query_provider.py +64 -0
  4. agentic_devtools/adapters/issue_provider.py +65 -0
  5. agentic_devtools/adapters/jira_provider.py +140 -75
  6. agentic_devtools/adapters/operation_plan.py +111 -0
  7. agentic_devtools/adapters/orchestration_key.py +39 -15
  8. agentic_devtools/adapters/plan_manifest.py +437 -0
  9. agentic_devtools/orchestration/graph_builder.py +15 -5
  10. agentic_devtools/orchestration/nodes/__init__.py +31 -0
  11. agentic_devtools/orchestration/nodes/_helpers.py +261 -0
  12. agentic_devtools/orchestration/nodes/checklist_creation.py +139 -0
  13. agentic_devtools/orchestration/nodes/commit.py +169 -0
  14. agentic_devtools/orchestration/nodes/completion.py +163 -0
  15. agentic_devtools/orchestration/nodes/implementation.py +395 -0
  16. agentic_devtools/orchestration/nodes/implementation_review.py +126 -0
  17. agentic_devtools/orchestration/nodes/initiate.py +136 -0
  18. agentic_devtools/orchestration/nodes/planning.py +221 -0
  19. agentic_devtools/orchestration/nodes/pull_request.py +256 -0
  20. agentic_devtools/orchestration/nodes/retrieve.py +198 -0
  21. agentic_devtools/orchestration/nodes/setup.py +123 -0
  22. agentic_devtools/orchestration/nodes/verification.py +96 -0
  23. agentic_devtools/orchestration/pilot_workflow.py +53 -21
  24. agentic_devtools/orchestration/runner.py +62 -9
  25. agentic_devtools/orchestration/state_schema.py +24 -0
  26. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/METADATA +1 -1
  27. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/RECORD +30 -14
  28. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/WHEEL +0 -0
  29. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/entry_points.txt +0 -0
  30. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/licenses/LICENSE +0 -0
@@ -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)
@@ -3,16 +3,21 @@
3
3
  from langgraph.graph import END, StateGraph
4
4
  from langgraph.graph.state import CompiledStateGraph
5
5
 
6
- from .pilot_workflow import (
6
+ from .nodes import (
7
7
  checklist_creation_node,
8
8
  commit_node,
9
9
  completion_node,
10
- error_handler_node,
11
10
  implementation_node,
12
11
  implementation_review_node,
13
12
  initiate_node,
14
13
  planning_node,
15
14
  pull_request_node,
15
+ retrieve_node,
16
+ setup_node,
17
+ verification_node,
18
+ )
19
+ from .pilot_workflow import (
20
+ error_handler_node,
16
21
  route_after_checklist_creation,
17
22
  route_after_commit,
18
23
  route_after_implementation,
@@ -20,10 +25,9 @@ from .pilot_workflow import (
20
25
  route_after_initiate,
21
26
  route_after_plan,
22
27
  route_after_pull_request,
28
+ route_after_retrieve,
23
29
  route_after_setup,
24
30
  route_after_verify,
25
- setup_node,
26
- verification_node,
27
31
  )
28
32
  from .state_schema import WorkOnIssueState
29
33
 
@@ -44,6 +48,7 @@ def build_work_on_issue_graph(checkpointer=None) -> CompiledStateGraph:
44
48
  # -- nodes ---------------------------------------------------------------
45
49
  graph.add_node("initiate", initiate_node)
46
50
  graph.add_node("setup", setup_node)
51
+ graph.add_node("retrieve", retrieve_node)
47
52
  graph.add_node("planning", planning_node)
48
53
  graph.add_node("checklist_creation", checklist_creation_node)
49
54
  graph.add_node("implementation", implementation_node)
@@ -61,11 +66,16 @@ def build_work_on_issue_graph(checkpointer=None) -> CompiledStateGraph:
61
66
  graph.add_conditional_edges(
62
67
  "initiate",
63
68
  route_after_initiate,
64
- {"setup": "setup", "planning": "planning", "error_handler": "error_handler"},
69
+ {"setup": "setup", "error_handler": "error_handler"},
65
70
  )
66
71
  graph.add_conditional_edges(
67
72
  "setup",
68
73
  route_after_setup,
74
+ {"retrieve": "retrieve", "error_handler": "error_handler"},
75
+ )
76
+ graph.add_conditional_edges(
77
+ "retrieve",
78
+ route_after_retrieve,
69
79
  {"planning": "planning", "error_handler": "error_handler"},
70
80
  )
71
81
  graph.add_conditional_edges(
@@ -0,0 +1,31 @@
1
+ """Real node implementations for the work-on-issue LangGraph workflow.
2
+
3
+ Re-exports all node functions so they can be imported from a single location:
4
+ ``from agentic_devtools.orchestration.nodes import initiate_node, ...``
5
+ """
6
+
7
+ from agentic_devtools.orchestration.nodes.checklist_creation import checklist_creation_node
8
+ from agentic_devtools.orchestration.nodes.commit import commit_node
9
+ from agentic_devtools.orchestration.nodes.completion import completion_node
10
+ from agentic_devtools.orchestration.nodes.implementation import implementation_node
11
+ from agentic_devtools.orchestration.nodes.implementation_review import implementation_review_node
12
+ from agentic_devtools.orchestration.nodes.initiate import initiate_node
13
+ from agentic_devtools.orchestration.nodes.planning import planning_node
14
+ from agentic_devtools.orchestration.nodes.pull_request import pull_request_node
15
+ from agentic_devtools.orchestration.nodes.retrieve import retrieve_node
16
+ from agentic_devtools.orchestration.nodes.setup import setup_node
17
+ from agentic_devtools.orchestration.nodes.verification import verification_node
18
+
19
+ __all__ = [
20
+ "checklist_creation_node",
21
+ "commit_node",
22
+ "completion_node",
23
+ "implementation_node",
24
+ "implementation_review_node",
25
+ "initiate_node",
26
+ "planning_node",
27
+ "pull_request_node",
28
+ "retrieve_node",
29
+ "setup_node",
30
+ "verification_node",
31
+ ]