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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Shared helper utilities for work-on-issue node implementations.
|
|
2
|
+
|
|
3
|
+
Provides issue key parsing, provider detection, LLM call wrappers,
|
|
4
|
+
and repository context discovery helpers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def utc_now() -> str:
|
|
17
|
+
"""Return the current UTC time as an ISO-8601 string."""
|
|
18
|
+
return datetime.now(timezone.utc).isoformat()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Issue key parsing and provider detection
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
_JIRA_KEY_PATTERN = re.compile(r"^[A-Z][A-Z0-9]+-\d+$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def detect_issue_provider(issue_key: str) -> str:
|
|
29
|
+
"""Detect whether an issue key is Jira or GitHub format.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
issue_key: The issue key to classify.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
``"jira"`` for PROJECT-NNN format, ``"github"`` for numeric or #N format.
|
|
36
|
+
"""
|
|
37
|
+
cleaned_issue_key = issue_key.strip()
|
|
38
|
+
if not cleaned_issue_key:
|
|
39
|
+
return "github"
|
|
40
|
+
# Strip leading # for GitHub issues
|
|
41
|
+
normalized = cleaned_issue_key.lstrip("#")
|
|
42
|
+
if normalized.isdigit():
|
|
43
|
+
return "github"
|
|
44
|
+
jira_candidate = cleaned_issue_key.upper()
|
|
45
|
+
if _JIRA_KEY_PATTERN.match(jira_candidate):
|
|
46
|
+
return "jira"
|
|
47
|
+
return "github"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_issue_key(issue_key: str) -> str:
|
|
51
|
+
"""Normalize an issue key for consistent usage.
|
|
52
|
+
|
|
53
|
+
Strips leading ``#`` characters from GitHub issue numbers.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
issue_key: Raw issue key input.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Normalized issue key string.
|
|
60
|
+
"""
|
|
61
|
+
cleaned_issue_key = issue_key.strip()
|
|
62
|
+
if cleaned_issue_key.startswith("#"):
|
|
63
|
+
return cleaned_issue_key.lstrip("#")
|
|
64
|
+
return cleaned_issue_key
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Subprocess helpers
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_command(
|
|
73
|
+
args: list[str],
|
|
74
|
+
*,
|
|
75
|
+
capture_output: bool = True,
|
|
76
|
+
timeout: int = 300,
|
|
77
|
+
cwd: str | None = None,
|
|
78
|
+
) -> subprocess.CompletedProcess[str]:
|
|
79
|
+
"""Run a subprocess command with standard error handling.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
args: Command and arguments to run.
|
|
83
|
+
capture_output: Whether to capture stdout/stderr.
|
|
84
|
+
timeout: Timeout in seconds.
|
|
85
|
+
cwd: Working directory for the command.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
CompletedProcess with return code and output.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def _to_text(value: bytes | str | None) -> str:
|
|
92
|
+
if isinstance(value, bytes):
|
|
93
|
+
return value.decode("utf-8", errors="replace")
|
|
94
|
+
if isinstance(value, str):
|
|
95
|
+
return value
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
return subprocess.run(
|
|
100
|
+
args,
|
|
101
|
+
capture_output=capture_output,
|
|
102
|
+
text=True,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
cwd=cwd,
|
|
105
|
+
shell=False,
|
|
106
|
+
)
|
|
107
|
+
except subprocess.TimeoutExpired as exc:
|
|
108
|
+
partial_stdout = _to_text(exc.stdout if exc.stdout is not None else exc.output)
|
|
109
|
+
partial_stderr = _to_text(exc.stderr) if exc.stderr is not None else ""
|
|
110
|
+
timeout_msg = f"Command timed out after {timeout}s: {' '.join(args)}"
|
|
111
|
+
return subprocess.CompletedProcess(
|
|
112
|
+
args=args,
|
|
113
|
+
returncode=124,
|
|
114
|
+
stdout=partial_stdout,
|
|
115
|
+
stderr=f"{timeout_msg}\n{partial_stderr}".rstrip(),
|
|
116
|
+
)
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
return subprocess.CompletedProcess(
|
|
119
|
+
args=args,
|
|
120
|
+
returncode=127,
|
|
121
|
+
stdout="",
|
|
122
|
+
stderr=f"Command not found: {args[0] if args else '<empty command>'}",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Repository context discovery (FR-010)
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def scan_directory_structure(
|
|
132
|
+
root: Path,
|
|
133
|
+
*,
|
|
134
|
+
max_depth: int = 3,
|
|
135
|
+
exclude_patterns: tuple[str, ...] = (
|
|
136
|
+
"__pycache__",
|
|
137
|
+
".git",
|
|
138
|
+
"node_modules",
|
|
139
|
+
".venv",
|
|
140
|
+
"venv",
|
|
141
|
+
".agdt",
|
|
142
|
+
".mypy_cache",
|
|
143
|
+
".pytest_cache",
|
|
144
|
+
".ruff_cache",
|
|
145
|
+
),
|
|
146
|
+
) -> list[str]:
|
|
147
|
+
"""Scan directory structure for repository context.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
root: Root directory to scan.
|
|
151
|
+
max_depth: Maximum directory depth to traverse.
|
|
152
|
+
exclude_patterns: Directory names to exclude.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
List of relative file paths found.
|
|
156
|
+
"""
|
|
157
|
+
if max_depth <= 0:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
paths: list[str] = []
|
|
161
|
+
_scan_recursive(root, root, 0, max_depth, exclude_patterns, paths)
|
|
162
|
+
return sorted(paths)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _scan_recursive(
|
|
166
|
+
root: Path,
|
|
167
|
+
current: Path,
|
|
168
|
+
depth: int,
|
|
169
|
+
max_depth: int,
|
|
170
|
+
exclude_patterns: tuple[str, ...],
|
|
171
|
+
results: list[str],
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Recursively scan directory tree."""
|
|
174
|
+
if depth >= max_depth:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
entries = sorted(current.iterdir())
|
|
178
|
+
except (PermissionError, FileNotFoundError):
|
|
179
|
+
return
|
|
180
|
+
for entry in entries:
|
|
181
|
+
if entry.name in exclude_patterns:
|
|
182
|
+
continue
|
|
183
|
+
relative = str(entry.relative_to(root))
|
|
184
|
+
if entry.is_file():
|
|
185
|
+
results.append(relative)
|
|
186
|
+
elif entry.is_dir():
|
|
187
|
+
results.append(relative + "/")
|
|
188
|
+
_scan_recursive(root, entry, depth + 1, max_depth, exclude_patterns, results)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def detect_test_conventions(root: Path) -> dict[str, Any]:
|
|
192
|
+
"""Detect testing conventions in the repository.
|
|
193
|
+
|
|
194
|
+
Checks for 1:1:1 test layout (tests/unit/) and common test patterns.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
root: Repository root path.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Dictionary describing detected test conventions.
|
|
201
|
+
"""
|
|
202
|
+
conventions: dict[str, Any] = {
|
|
203
|
+
"has_tests_unit": (root / "tests" / "unit").is_dir(),
|
|
204
|
+
"has_tests_dir": (root / "tests").is_dir(),
|
|
205
|
+
"test_layout": "unknown",
|
|
206
|
+
}
|
|
207
|
+
if conventions["has_tests_unit"]:
|
|
208
|
+
conventions["test_layout"] = "1:1:1"
|
|
209
|
+
elif conventions["has_tests_dir"]:
|
|
210
|
+
conventions["test_layout"] = "flat"
|
|
211
|
+
return conventions
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _to_nonneg_int(value: Any) -> int:
|
|
215
|
+
"""Coerce a potentially-corrupted state value to a non-negative integer.
|
|
216
|
+
|
|
217
|
+
Handles ``None``, ``bool``, ``str``, and numeric inputs from checkpoints
|
|
218
|
+
that may have been corrupted or migrated from older state schemas.
|
|
219
|
+
``bool`` is checked before ``int`` because ``bool`` is a subclass of
|
|
220
|
+
``int`` and ``True``/``False`` should not be treated as ``1``/``0``.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
value: Arbitrary value from workflow state.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Non-negative integer (0 if coercion fails or result is negative).
|
|
227
|
+
"""
|
|
228
|
+
if isinstance(value, bool):
|
|
229
|
+
return 0
|
|
230
|
+
if isinstance(value, int):
|
|
231
|
+
return max(0, value)
|
|
232
|
+
if isinstance(value, float):
|
|
233
|
+
return max(0, int(value))
|
|
234
|
+
if isinstance(value, str):
|
|
235
|
+
try:
|
|
236
|
+
return max(0, int(value))
|
|
237
|
+
except ValueError:
|
|
238
|
+
return 0
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def read_file_content(path: Path, *, max_chars: int = 10000) -> str:
|
|
243
|
+
"""Read file content with a character budget.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
path: File path to read.
|
|
247
|
+
max_chars: Maximum characters to return.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
File content truncated to at most max_chars characters.
|
|
251
|
+
"""
|
|
252
|
+
_suffix = "\n... [truncated]"
|
|
253
|
+
try:
|
|
254
|
+
content = path.read_text(encoding="utf-8")
|
|
255
|
+
if len(content) > max_chars:
|
|
256
|
+
if max_chars <= len(_suffix):
|
|
257
|
+
return content[:max_chars]
|
|
258
|
+
return content[: max_chars - len(_suffix)] + _suffix
|
|
259
|
+
return content
|
|
260
|
+
except (OSError, UnicodeDecodeError):
|
|
261
|
+
return ""
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Checklist creation node: generate structured checklist from plan.
|
|
2
|
+
|
|
3
|
+
Calls the LLM to decompose the implementation plan into discrete,
|
|
4
|
+
actionable checklist items with acceptance criteria.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from agentic_devtools.orchestration.execution.context_factory import _run_async
|
|
13
|
+
from agentic_devtools.orchestration.nodes._helpers import _to_nonneg_int, utc_now
|
|
14
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def checklist_creation_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
18
|
+
"""Generate structured checklist from the implementation plan.
|
|
19
|
+
|
|
20
|
+
Calls LLM with plan context to produce a list of ChecklistItem entries.
|
|
21
|
+
"""
|
|
22
|
+
plan = state.get("plan", "")
|
|
23
|
+
issue_key = state.get("issue_key", "")
|
|
24
|
+
|
|
25
|
+
if not plan:
|
|
26
|
+
return {
|
|
27
|
+
"step": "checklist_creation",
|
|
28
|
+
"error": "No plan available to generate checklist from.",
|
|
29
|
+
"events": [
|
|
30
|
+
{
|
|
31
|
+
"event": "checklist_creation_failed",
|
|
32
|
+
"timestamp": utc_now(),
|
|
33
|
+
"signals": {"error": "no_plan"},
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
checklist_result = _generate_checklist(issue_key, plan)
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
return {
|
|
42
|
+
"step": "checklist_creation",
|
|
43
|
+
"error": f"Checklist generation failed: {exc}",
|
|
44
|
+
"events": [
|
|
45
|
+
{
|
|
46
|
+
"event": "checklist_creation_failed",
|
|
47
|
+
"timestamp": utc_now(),
|
|
48
|
+
"signals": {"error": str(exc)},
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
checklist_items = checklist_result.get("items", [])
|
|
54
|
+
token_usage = checklist_result.get("token_usage", {})
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"step": "checklist_creation",
|
|
58
|
+
"error": None,
|
|
59
|
+
"checklist_items": checklist_items,
|
|
60
|
+
"checklist_created": True,
|
|
61
|
+
"token_usage_prompt": _to_nonneg_int(state.get("token_usage_prompt"))
|
|
62
|
+
+ _to_nonneg_int(token_usage.get("prompt_tokens")),
|
|
63
|
+
"token_usage_completion": _to_nonneg_int(state.get("token_usage_completion"))
|
|
64
|
+
+ _to_nonneg_int(token_usage.get("completion_tokens")),
|
|
65
|
+
"events": [
|
|
66
|
+
{
|
|
67
|
+
"event": "checklist_creation_completed",
|
|
68
|
+
"timestamp": utc_now(),
|
|
69
|
+
"signals": {"item_count": len(checklist_items)},
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _generate_checklist(issue_key: str, plan: str) -> dict[str, Any]:
|
|
76
|
+
"""Generate checklist items via LLM provider.
|
|
77
|
+
|
|
78
|
+
Returns dict with keys: items (list of dicts), token_usage.
|
|
79
|
+
"""
|
|
80
|
+
from agentic_devtools.orchestration.llm.factory import ProviderFactory
|
|
81
|
+
|
|
82
|
+
factory = ProviderFactory()
|
|
83
|
+
provider = factory.get_provider("checklist_creation", "work_on_issue")
|
|
84
|
+
|
|
85
|
+
system_prompt = (
|
|
86
|
+
"You are an implementation checklist generator. Given an implementation plan, "
|
|
87
|
+
"break it down into discrete, actionable checklist items. Each item should be "
|
|
88
|
+
"small enough to implement in a single TDD cycle (write test, implement, verify).\n\n"
|
|
89
|
+
"Respond with a JSON object containing:\n"
|
|
90
|
+
'- "items": list of objects, each with:\n'
|
|
91
|
+
' - "description": what needs to be done\n'
|
|
92
|
+
' - "acceptance_criteria": how to verify completion\n'
|
|
93
|
+
' - "estimated_complexity": "low", "medium", or "high"\n'
|
|
94
|
+
' - "is_complete": false\n'
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
user_prompt = f"Issue: {issue_key}\n\nImplementation Plan:\n{plan}"
|
|
98
|
+
|
|
99
|
+
async def _call_llm():
|
|
100
|
+
from agentic_devtools.orchestration.llm.types import LLMMessage
|
|
101
|
+
|
|
102
|
+
messages = [
|
|
103
|
+
LLMMessage(role="system", content=system_prompt),
|
|
104
|
+
LLMMessage(role="user", content=user_prompt),
|
|
105
|
+
]
|
|
106
|
+
response = await provider.complete(messages)
|
|
107
|
+
return response
|
|
108
|
+
|
|
109
|
+
response = _run_async(_call_llm())
|
|
110
|
+
|
|
111
|
+
token_usage = {}
|
|
112
|
+
if response.usage:
|
|
113
|
+
token_usage = {
|
|
114
|
+
"prompt_tokens": response.usage.input_tokens,
|
|
115
|
+
"completion_tokens": response.usage.output_tokens,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_FALLBACK_ITEM = [
|
|
119
|
+
{
|
|
120
|
+
"description": "Implement the plan as described",
|
|
121
|
+
"acceptance_criteria": "All tests pass",
|
|
122
|
+
"estimated_complexity": "medium",
|
|
123
|
+
"is_complete": False,
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
parsed = json.loads(response.text)
|
|
129
|
+
if isinstance(parsed, dict):
|
|
130
|
+
items = parsed.get("items", [])
|
|
131
|
+
if not (isinstance(items, list) and items):
|
|
132
|
+
items = _FALLBACK_ITEM
|
|
133
|
+
else:
|
|
134
|
+
items = _FALLBACK_ITEM
|
|
135
|
+
except (json.JSONDecodeError, TypeError):
|
|
136
|
+
# Fallback: create a single item from the plan
|
|
137
|
+
items = _FALLBACK_ITEM
|
|
138
|
+
|
|
139
|
+
return {"items": items, "token_usage": token_usage}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Commit node: generate commit message and invoke agdt-git-save-work.
|
|
2
|
+
|
|
3
|
+
Creates a conventional commit message following ``type(#NNN): summary``
|
|
4
|
+
for GitHub issues or ``type(PROJECT-NNN): summary`` for Jira keys,
|
|
5
|
+
and uses ``agdt-git-save-work`` which handles smart amend detection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agentic_devtools.orchestration.nodes._helpers import (
|
|
14
|
+
normalize_issue_key,
|
|
15
|
+
run_command,
|
|
16
|
+
utc_now,
|
|
17
|
+
)
|
|
18
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def commit_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
22
|
+
"""Generate commit message and save work.
|
|
23
|
+
|
|
24
|
+
Builds a conventional commit message and invokes ``agdt-git-save-work``
|
|
25
|
+
which handles smart amend detection automatically.
|
|
26
|
+
|
|
27
|
+
Fails fast when ``issue_key`` is missing, blank, or not a string so a
|
|
28
|
+
corrupted/resumed checkpoint cannot generate an invalid ``feat():``
|
|
29
|
+
commit or raise ``AttributeError`` inside ``normalize_issue_key()``.
|
|
30
|
+
"""
|
|
31
|
+
issue_key = state.get("issue_key", "")
|
|
32
|
+
if not isinstance(issue_key, str) or not issue_key.strip():
|
|
33
|
+
return {
|
|
34
|
+
"step": "commit",
|
|
35
|
+
"error": "issue_key is required and must be a non-empty string",
|
|
36
|
+
"events": [
|
|
37
|
+
{
|
|
38
|
+
"event": "commit_failed",
|
|
39
|
+
"timestamp": utc_now(),
|
|
40
|
+
"signals": {"error": "missing_issue_key"},
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
normalized_key = normalize_issue_key(issue_key)
|
|
45
|
+
if not normalized_key:
|
|
46
|
+
return {
|
|
47
|
+
"step": "commit",
|
|
48
|
+
"error": "issue_key must normalize to a non-empty issue identifier",
|
|
49
|
+
"events": [
|
|
50
|
+
{
|
|
51
|
+
"event": "commit_failed",
|
|
52
|
+
"timestamp": utc_now(),
|
|
53
|
+
"signals": {"error": "invalid_issue_key"},
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
plan = state.get("plan", "")
|
|
58
|
+
issue_data = state.get("issue_data", {})
|
|
59
|
+
|
|
60
|
+
# Generate commit message
|
|
61
|
+
issue_provider = state.get("issue_provider")
|
|
62
|
+
commit_message = _generate_commit_message(normalized_key, plan, issue_data, issue_provider)
|
|
63
|
+
|
|
64
|
+
# Invoke agdt-git-save-work
|
|
65
|
+
result = run_command(
|
|
66
|
+
["agdt-git-save-work", "--commit-message", commit_message],
|
|
67
|
+
timeout=120,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if result.returncode != 0:
|
|
71
|
+
return {
|
|
72
|
+
"step": "commit",
|
|
73
|
+
"error": f"git save-work failed: {result.stderr.strip()}",
|
|
74
|
+
"commit_message": commit_message,
|
|
75
|
+
"events": [
|
|
76
|
+
{
|
|
77
|
+
"event": "commit_failed",
|
|
78
|
+
"timestamp": utc_now(),
|
|
79
|
+
"signals": {"error": result.stderr.strip()},
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Wait for background task
|
|
85
|
+
wait_result = run_command(["agdt-task-wait"], timeout=120)
|
|
86
|
+
if wait_result.returncode != 0:
|
|
87
|
+
return {
|
|
88
|
+
"step": "commit",
|
|
89
|
+
"error": f"agdt-task-wait failed after commit: {wait_result.stderr.strip()}",
|
|
90
|
+
"commit_message": commit_message,
|
|
91
|
+
"events": [
|
|
92
|
+
{
|
|
93
|
+
"event": "commit_wait_failed",
|
|
94
|
+
"timestamp": utc_now(),
|
|
95
|
+
"signals": {"error": wait_result.stderr.strip()},
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"step": "commit",
|
|
102
|
+
"error": None,
|
|
103
|
+
"commit_created": True,
|
|
104
|
+
"branch_pushed": True,
|
|
105
|
+
"commit_message": commit_message,
|
|
106
|
+
"events": [
|
|
107
|
+
{
|
|
108
|
+
"event": "commit_completed",
|
|
109
|
+
"timestamp": utc_now(),
|
|
110
|
+
"signals": {"commit_created": True, "branch_pushed": True},
|
|
111
|
+
}
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _generate_commit_message(
|
|
117
|
+
issue_key: str,
|
|
118
|
+
plan: str,
|
|
119
|
+
issue_data: Any,
|
|
120
|
+
issue_provider: str | None = None,
|
|
121
|
+
) -> str:
|
|
122
|
+
"""Generate a conventional commit message.
|
|
123
|
+
|
|
124
|
+
Format: ``feat(#NNN): summary\\n\\nbody\\n\\n#NNN`` for GitHub issues or
|
|
125
|
+
``feat(PROJECT-NNN): summary\\n\\nbody\\n\\n[PROJECT-NNN](https://<jira>/browse/PROJECT-NNN)``
|
|
126
|
+
for Jira keys.
|
|
127
|
+
"""
|
|
128
|
+
summary = ""
|
|
129
|
+
if isinstance(issue_data, dict):
|
|
130
|
+
raw_summary = issue_data.get("summary", "")
|
|
131
|
+
if isinstance(raw_summary, str):
|
|
132
|
+
summary = raw_summary
|
|
133
|
+
|
|
134
|
+
if not isinstance(plan, str):
|
|
135
|
+
plan = ""
|
|
136
|
+
|
|
137
|
+
if not summary:
|
|
138
|
+
# Extract from plan if available
|
|
139
|
+
summary = "implement autonomous workflow"
|
|
140
|
+
if plan:
|
|
141
|
+
first_line = plan.split("\n")[0][:50]
|
|
142
|
+
if first_line:
|
|
143
|
+
summary = first_line.lower()
|
|
144
|
+
|
|
145
|
+
# Determine scope prefix
|
|
146
|
+
normalized = issue_key.lstrip("#")
|
|
147
|
+
if normalized.isdigit():
|
|
148
|
+
scope = f"#{normalized}"
|
|
149
|
+
provider = "github"
|
|
150
|
+
else:
|
|
151
|
+
scope = normalized
|
|
152
|
+
provider = "jira"
|
|
153
|
+
|
|
154
|
+
if isinstance(issue_provider, str) and issue_provider in {"github", "jira"}:
|
|
155
|
+
provider = issue_provider
|
|
156
|
+
|
|
157
|
+
footer = scope
|
|
158
|
+
if provider == "jira":
|
|
159
|
+
jira_base_url = os.environ.get("JIRA_BASE_URL", "https://jira.swica.ch").rstrip("/")
|
|
160
|
+
footer = f"[{scope}]({jira_base_url}/browse/{scope})"
|
|
161
|
+
|
|
162
|
+
title = f"feat({scope}): {summary}"
|
|
163
|
+
# Ensure title is not too long
|
|
164
|
+
if len(title) > 72:
|
|
165
|
+
title = title[:69] + "..."
|
|
166
|
+
|
|
167
|
+
body = f"Autonomous implementation via LangChain work-on-issue workflow.\n\n{footer}"
|
|
168
|
+
|
|
169
|
+
return f"{title}\n\n{body}"
|