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,136 @@
|
|
|
1
|
+
"""Initiate node: validate LLM config and detect issue provider.
|
|
2
|
+
|
|
3
|
+
Entry point for the work-on-issue workflow. Validates that the LLM
|
|
4
|
+
provider configuration exists and determines whether the issue is
|
|
5
|
+
from Jira or GitHub based on the key format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from agentic_devtools.orchestration.nodes._helpers import detect_issue_provider, normalize_issue_key, utc_now
|
|
13
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def initiate_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
17
|
+
"""Validate LLM config and detect issue provider.
|
|
18
|
+
|
|
19
|
+
Reads the issue key from state, detects the provider (Jira vs GitHub),
|
|
20
|
+
validates that an LLM provider is configured, and sets routing signals.
|
|
21
|
+
|
|
22
|
+
Fails fast with ``ProviderNotConfiguredError`` info in the error field
|
|
23
|
+
if no LLM provider configuration is found.
|
|
24
|
+
"""
|
|
25
|
+
issue_key = state.get("issue_key", "")
|
|
26
|
+
|
|
27
|
+
# Fail fast when issue_key is missing, blank, or not a string — downstream
|
|
28
|
+
# nodes (retrieve, pull_request, completion) all require a valid key, and
|
|
29
|
+
# detect_issue_provider("") silently returns "github" which produces
|
|
30
|
+
# confusing failures later.
|
|
31
|
+
if not isinstance(issue_key, str) or not issue_key.strip():
|
|
32
|
+
_missing_error = "issue_key is required and must be a non-empty string"
|
|
33
|
+
return {
|
|
34
|
+
"step": "initiate",
|
|
35
|
+
"status": "failed",
|
|
36
|
+
"error": _missing_error,
|
|
37
|
+
"events": [
|
|
38
|
+
{
|
|
39
|
+
"event": "initiate_failed",
|
|
40
|
+
"timestamp": utc_now(),
|
|
41
|
+
"signals": {"error": "missing_issue_key"},
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
normalized_key = normalize_issue_key(issue_key)
|
|
47
|
+
if not normalized_key:
|
|
48
|
+
invalid_error = "issue_key must normalize to a non-empty issue identifier"
|
|
49
|
+
return {
|
|
50
|
+
"step": "initiate",
|
|
51
|
+
"status": "failed",
|
|
52
|
+
"error": invalid_error,
|
|
53
|
+
"events": [
|
|
54
|
+
{
|
|
55
|
+
"event": "initiate_failed",
|
|
56
|
+
"timestamp": utc_now(),
|
|
57
|
+
"signals": {"error": "invalid_issue_key"},
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Detect issue provider from key format
|
|
63
|
+
issue_provider = detect_issue_provider(issue_key)
|
|
64
|
+
|
|
65
|
+
# Validate LLM configuration exists
|
|
66
|
+
llm_config_error = _validate_llm_config()
|
|
67
|
+
if llm_config_error:
|
|
68
|
+
return {
|
|
69
|
+
"step": "initiate",
|
|
70
|
+
"status": "failed",
|
|
71
|
+
"error": llm_config_error,
|
|
72
|
+
"issue_provider": issue_provider,
|
|
73
|
+
"events": [
|
|
74
|
+
{
|
|
75
|
+
"event": "initiate_failed",
|
|
76
|
+
"timestamp": utc_now(),
|
|
77
|
+
"signals": {"error": llm_config_error},
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Translate any pre-flight error (e.g., wrong worktree/branch) into the
|
|
83
|
+
# ``needs_setup`` routing signal. ``issue_retrieved`` is intentionally NOT set
|
|
84
|
+
# here: it means "the issue was actually fetched" and is set solely by
|
|
85
|
+
# ``retrieve_node`` (and read by ``route_after_retrieve``). Setting it in this
|
|
86
|
+
# node would overload the signal with a second meaning ("pre-flight OK").
|
|
87
|
+
# ``route_after_initiate`` always proceeds to ``setup`` when there is no error.
|
|
88
|
+
pre_flight_error = state.get("error")
|
|
89
|
+
needs_setup = bool(pre_flight_error)
|
|
90
|
+
|
|
91
|
+
# Include the original pre-flight error detail in signals to aid diagnostics
|
|
92
|
+
# when reviewing workflow events / checkpoints. Only include when it is a
|
|
93
|
+
# non-empty string; truthy non-string values (e.g. dicts from corrupted state)
|
|
94
|
+
# are omitted to keep the signal payload consistent.
|
|
95
|
+
signals: dict[str, Any] = {
|
|
96
|
+
"needs_setup": needs_setup,
|
|
97
|
+
"issue_provider": issue_provider,
|
|
98
|
+
}
|
|
99
|
+
if isinstance(pre_flight_error, str) and pre_flight_error:
|
|
100
|
+
signals["pre_flight_error_detail"] = pre_flight_error
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"step": "initiate",
|
|
104
|
+
"status": "active",
|
|
105
|
+
"error": None,
|
|
106
|
+
"issue_provider": issue_provider,
|
|
107
|
+
"needs_setup": needs_setup,
|
|
108
|
+
"events": [
|
|
109
|
+
{
|
|
110
|
+
"event": "initiate_completed",
|
|
111
|
+
"timestamp": utc_now(),
|
|
112
|
+
"signals": signals,
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _validate_llm_config() -> str | None:
|
|
119
|
+
"""Validate LLM provider configuration exists.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Error message string if configuration is invalid, None if valid.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
from agentic_devtools.orchestration.llm.config import load_config
|
|
126
|
+
|
|
127
|
+
config = load_config()
|
|
128
|
+
if not config.providers:
|
|
129
|
+
return (
|
|
130
|
+
"ProviderNotConfiguredError: No LLM providers configured in "
|
|
131
|
+
".agdt/config/llm-providers.yml. Configure at least one provider "
|
|
132
|
+
"before running the workflow."
|
|
133
|
+
)
|
|
134
|
+
return None
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
return f"ProviderNotConfiguredError: Failed to load LLM config: {exc}"
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Planning node: generate implementation plan via LLM.
|
|
2
|
+
|
|
3
|
+
Calls the configured LLM provider to analyze the issue and generate
|
|
4
|
+
a structured implementation plan. Includes blocked detection for
|
|
5
|
+
ambiguous or under-specified issues.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agentic_devtools.orchestration.execution.context_factory import _run_async
|
|
14
|
+
from agentic_devtools.orchestration.nodes._helpers import _to_nonneg_int, utc_now
|
|
15
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
16
|
+
|
|
17
|
+
_MIN_SUMMARY_LENGTH = 5
|
|
18
|
+
_MIN_DESCRIPTION_LENGTH = 20
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def planning_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
22
|
+
"""Generate implementation plan using LLM.
|
|
23
|
+
|
|
24
|
+
Builds a system prompt with issue context, calls the LLM for
|
|
25
|
+
structured output, and evaluates whether the issue is clear enough
|
|
26
|
+
to proceed. If blocked, sets ``blocked_reason`` and routes to error.
|
|
27
|
+
Fails fast when ``issue_key`` is absent, blank, or not a string.
|
|
28
|
+
"""
|
|
29
|
+
raw_issue_key = state.get("issue_key")
|
|
30
|
+
if not isinstance(raw_issue_key, str) or not raw_issue_key.strip():
|
|
31
|
+
return {
|
|
32
|
+
"step": "planning",
|
|
33
|
+
"error": "issue_key is missing or blank; cannot generate implementation plan.",
|
|
34
|
+
"events": [
|
|
35
|
+
{
|
|
36
|
+
"event": "planning_failed",
|
|
37
|
+
"timestamp": utc_now(),
|
|
38
|
+
"signals": {"error": "issue_key is missing or blank"},
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
issue_key: str = raw_issue_key
|
|
43
|
+
issue_data = state.get("issue_data", {})
|
|
44
|
+
issue_data_dict = issue_data if isinstance(issue_data, dict) else {}
|
|
45
|
+
|
|
46
|
+
# Build context for LLM; coerce to str so _check_blocked can safely call
|
|
47
|
+
# .strip() even when issue_data carries None or an ADF dict (Jira Cloud).
|
|
48
|
+
raw_summary = issue_data_dict.get("summary")
|
|
49
|
+
raw_description = issue_data_dict.get("description")
|
|
50
|
+
summary = raw_summary if isinstance(raw_summary, str) else ""
|
|
51
|
+
description = raw_description if isinstance(raw_description, str) else ""
|
|
52
|
+
|
|
53
|
+
# Check for obviously blocked issues (empty or trivial)
|
|
54
|
+
blocked_reason = _check_blocked(summary, description)
|
|
55
|
+
if blocked_reason:
|
|
56
|
+
return {
|
|
57
|
+
"step": "planning",
|
|
58
|
+
"status": "blocked",
|
|
59
|
+
"error": blocked_reason,
|
|
60
|
+
"blocked_reason": blocked_reason,
|
|
61
|
+
"plan_posted": False,
|
|
62
|
+
"events": [
|
|
63
|
+
{
|
|
64
|
+
"event": "planning_blocked",
|
|
65
|
+
"timestamp": utc_now(),
|
|
66
|
+
"signals": {"blocked_reason": blocked_reason},
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Call LLM for plan generation
|
|
72
|
+
try:
|
|
73
|
+
plan_result = _generate_plan(issue_key, issue_data_dict)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
return {
|
|
76
|
+
"step": "planning",
|
|
77
|
+
"error": f"Plan generation failed: {exc}",
|
|
78
|
+
"events": [
|
|
79
|
+
{
|
|
80
|
+
"event": "planning_failed",
|
|
81
|
+
"timestamp": utc_now(),
|
|
82
|
+
"signals": {"error": str(exc)},
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Check if LLM detected blocked state
|
|
88
|
+
if plan_result.get("is_blocked") is True:
|
|
89
|
+
raw_blocked = plan_result.get("blocked_reason", "")
|
|
90
|
+
blocked_reason = (
|
|
91
|
+
raw_blocked
|
|
92
|
+
if isinstance(raw_blocked, str) and raw_blocked.strip()
|
|
93
|
+
else "Issue is too ambiguous for autonomous implementation"
|
|
94
|
+
)
|
|
95
|
+
return {
|
|
96
|
+
"step": "planning",
|
|
97
|
+
"status": "blocked",
|
|
98
|
+
"error": blocked_reason,
|
|
99
|
+
"blocked_reason": blocked_reason,
|
|
100
|
+
"plan_posted": False,
|
|
101
|
+
"events": [
|
|
102
|
+
{
|
|
103
|
+
"event": "planning_blocked",
|
|
104
|
+
"timestamp": utc_now(),
|
|
105
|
+
"signals": {"blocked_reason": blocked_reason},
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
raw_plan = plan_result.get("plan", "")
|
|
111
|
+
plan_text = raw_plan if isinstance(raw_plan, str) else ""
|
|
112
|
+
raw_token_usage = plan_result.get("token_usage", {})
|
|
113
|
+
token_usage = raw_token_usage if isinstance(raw_token_usage, dict) else {}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"step": "planning",
|
|
117
|
+
"status": "active",
|
|
118
|
+
"error": None,
|
|
119
|
+
"blocked_reason": None,
|
|
120
|
+
"plan": plan_text,
|
|
121
|
+
"plan_posted": True,
|
|
122
|
+
"token_usage_prompt": _to_nonneg_int(state.get("token_usage_prompt"))
|
|
123
|
+
+ _to_nonneg_int(token_usage.get("prompt_tokens")),
|
|
124
|
+
"token_usage_completion": _to_nonneg_int(state.get("token_usage_completion"))
|
|
125
|
+
+ _to_nonneg_int(token_usage.get("completion_tokens")),
|
|
126
|
+
"events": [
|
|
127
|
+
{
|
|
128
|
+
"event": "planning_completed",
|
|
129
|
+
"timestamp": utc_now(),
|
|
130
|
+
"signals": {"plan_posted": True},
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _check_blocked(summary: str, description: str) -> str | None:
|
|
137
|
+
"""Check for obviously under-specified issues.
|
|
138
|
+
|
|
139
|
+
Returns a blocked reason string if the issue lacks sufficient detail,
|
|
140
|
+
or None if the issue appears actionable.
|
|
141
|
+
"""
|
|
142
|
+
if not summary or len(summary.strip()) < _MIN_SUMMARY_LENGTH:
|
|
143
|
+
return "Issue summary is empty or too short (less than 5 characters). Cannot generate implementation plan."
|
|
144
|
+
|
|
145
|
+
if not description or len(description.strip()) < _MIN_DESCRIPTION_LENGTH:
|
|
146
|
+
return (
|
|
147
|
+
"Issue description is empty or too brief (less than 20 characters). "
|
|
148
|
+
"Insufficient context for autonomous implementation."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _generate_plan(issue_key: str, issue_data: dict[str, Any]) -> dict[str, Any]:
|
|
155
|
+
"""Generate implementation plan via LLM provider.
|
|
156
|
+
|
|
157
|
+
Returns dict with keys: plan, is_blocked, blocked_reason, token_usage.
|
|
158
|
+
"""
|
|
159
|
+
from agentic_devtools.orchestration.llm.factory import ProviderFactory
|
|
160
|
+
|
|
161
|
+
factory = ProviderFactory()
|
|
162
|
+
provider = factory.get_provider("planning", "work_on_issue")
|
|
163
|
+
|
|
164
|
+
raw_summary = issue_data.get("summary")
|
|
165
|
+
raw_description = issue_data.get("description")
|
|
166
|
+
summary = raw_summary if isinstance(raw_summary, str) else ""
|
|
167
|
+
description = raw_description if isinstance(raw_description, str) else ""
|
|
168
|
+
|
|
169
|
+
system_prompt = (
|
|
170
|
+
"You are an implementation planning assistant. Given an issue description, "
|
|
171
|
+
"generate a structured implementation plan. If the issue is too vague or "
|
|
172
|
+
"contradictory to implement autonomously, respond with is_blocked=true.\n\n"
|
|
173
|
+
"Respond with a JSON object containing:\n"
|
|
174
|
+
'- "is_blocked": boolean (true if issue is too ambiguous)\n'
|
|
175
|
+
'- "blocked_reason": string (explanation if blocked, empty otherwise)\n'
|
|
176
|
+
'- "missing_information": list of strings (what info is needed if blocked)\n'
|
|
177
|
+
'- "plan": string (implementation plan if not blocked)\n'
|
|
178
|
+
'- "tasks": list of task descriptions\n'
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
user_prompt = f"Issue Key: {issue_key}\nSummary: {summary}\n\nDescription:\n{description}"
|
|
182
|
+
|
|
183
|
+
async def _call_llm():
|
|
184
|
+
from agentic_devtools.orchestration.llm.types import LLMMessage
|
|
185
|
+
|
|
186
|
+
messages = [
|
|
187
|
+
LLMMessage(role="system", content=system_prompt),
|
|
188
|
+
LLMMessage(role="user", content=user_prompt),
|
|
189
|
+
]
|
|
190
|
+
response = await provider.complete(messages)
|
|
191
|
+
return response
|
|
192
|
+
|
|
193
|
+
response = _run_async(_call_llm())
|
|
194
|
+
|
|
195
|
+
# Parse response
|
|
196
|
+
token_usage = {}
|
|
197
|
+
if response.usage:
|
|
198
|
+
token_usage = {
|
|
199
|
+
"prompt_tokens": response.usage.input_tokens,
|
|
200
|
+
"completion_tokens": response.usage.output_tokens,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Try to parse structured response
|
|
204
|
+
try:
|
|
205
|
+
parsed = json.loads(response.text)
|
|
206
|
+
if isinstance(parsed, dict):
|
|
207
|
+
return {
|
|
208
|
+
"plan": parsed.get("plan", response.text),
|
|
209
|
+
"is_blocked": parsed.get("is_blocked", False),
|
|
210
|
+
"blocked_reason": parsed.get("blocked_reason", ""),
|
|
211
|
+
"token_usage": token_usage,
|
|
212
|
+
}
|
|
213
|
+
except (json.JSONDecodeError, TypeError):
|
|
214
|
+
pass
|
|
215
|
+
# If response isn't valid JSON, or parsed to a non-dict type, treat it as the plan text
|
|
216
|
+
return {
|
|
217
|
+
"plan": response.text,
|
|
218
|
+
"is_blocked": False,
|
|
219
|
+
"blocked_reason": "",
|
|
220
|
+
"token_usage": token_usage,
|
|
221
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Pull request node: create PR on the appropriate platform.
|
|
2
|
+
|
|
3
|
+
Detects the platform (Azure DevOps vs GitHub) from the ``issue_provider``
|
|
4
|
+
state field and creates a draft pull request with appropriate metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from agentic_devtools.orchestration.nodes._helpers import (
|
|
13
|
+
detect_issue_provider,
|
|
14
|
+
normalize_issue_key,
|
|
15
|
+
run_command,
|
|
16
|
+
utc_now,
|
|
17
|
+
)
|
|
18
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pull_request_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
22
|
+
"""Create pull request on the appropriate platform.
|
|
23
|
+
|
|
24
|
+
Uses ``issue_provider`` when it is ``"github"``/``"jira"``, otherwise derives
|
|
25
|
+
the platform from ``issue_key`` (GitHub numeric/#N vs Jira PROJECT-N).
|
|
26
|
+
Creates as draft by default.
|
|
27
|
+
|
|
28
|
+
Fails fast when ``issue_key`` is missing, blank, or not a string so a
|
|
29
|
+
corrupted/resumed checkpoint cannot raise ``AttributeError`` inside
|
|
30
|
+
``detect_issue_provider()`` or ``normalize_issue_key()``, or silently
|
|
31
|
+
build a PR title with an empty scope.
|
|
32
|
+
"""
|
|
33
|
+
issue_key = state.get("issue_key", "")
|
|
34
|
+
if not isinstance(issue_key, str) or not issue_key.strip():
|
|
35
|
+
return {
|
|
36
|
+
"step": "pull_request",
|
|
37
|
+
"error": "issue_key is required and must be a non-empty string",
|
|
38
|
+
"events": [
|
|
39
|
+
{
|
|
40
|
+
"event": "pull_request_failed",
|
|
41
|
+
"timestamp": utc_now(),
|
|
42
|
+
"signals": {"error": "missing_issue_key"},
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
raw_issue_provider = state.get("issue_provider")
|
|
47
|
+
issue_provider = (
|
|
48
|
+
raw_issue_provider
|
|
49
|
+
if isinstance(raw_issue_provider, str) and raw_issue_provider in {"jira", "github"}
|
|
50
|
+
else detect_issue_provider(issue_key)
|
|
51
|
+
)
|
|
52
|
+
normalized_key = normalize_issue_key(issue_key)
|
|
53
|
+
if not normalized_key:
|
|
54
|
+
return {
|
|
55
|
+
"step": "pull_request",
|
|
56
|
+
"error": "issue_key must normalize to a non-empty issue identifier",
|
|
57
|
+
"events": [
|
|
58
|
+
{
|
|
59
|
+
"event": "pull_request_failed",
|
|
60
|
+
"timestamp": utc_now(),
|
|
61
|
+
"signals": {"error": "invalid_issue_key"},
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
plan = state.get("plan", "")
|
|
66
|
+
issue_data = state.get("issue_data", {})
|
|
67
|
+
|
|
68
|
+
# Generate PR title and description
|
|
69
|
+
pr_title = _generate_pr_title(normalized_key, issue_data)
|
|
70
|
+
pr_description = _generate_pr_description(normalized_key, plan, issue_data)
|
|
71
|
+
|
|
72
|
+
# Get current branch — fail fast if git cannot determine it
|
|
73
|
+
branch_result = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
74
|
+
if branch_result.returncode != 0:
|
|
75
|
+
error_detail = branch_result.stderr.strip() or "Unable to determine current branch"
|
|
76
|
+
return {
|
|
77
|
+
"step": "pull_request",
|
|
78
|
+
"error": f"Cannot create PR: failed to determine current branch: {error_detail}",
|
|
79
|
+
"events": [
|
|
80
|
+
{
|
|
81
|
+
"event": "pull_request_failed",
|
|
82
|
+
"timestamp": utc_now(),
|
|
83
|
+
"signals": {"error": "branch_detection_failed"},
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
}
|
|
87
|
+
source_branch = branch_result.stdout.strip()
|
|
88
|
+
if not source_branch:
|
|
89
|
+
return {
|
|
90
|
+
"step": "pull_request",
|
|
91
|
+
"error": "Cannot create PR: failed to determine current branch: Unable to determine current branch",
|
|
92
|
+
"events": [
|
|
93
|
+
{
|
|
94
|
+
"event": "pull_request_failed",
|
|
95
|
+
"timestamp": utc_now(),
|
|
96
|
+
"signals": {"error": "branch_detection_failed"},
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Create PR based on platform
|
|
102
|
+
if issue_provider == "github":
|
|
103
|
+
result = _create_github_pr(pr_title, pr_description)
|
|
104
|
+
else:
|
|
105
|
+
result = _create_azure_devops_pr(pr_title, pr_description, source_branch)
|
|
106
|
+
|
|
107
|
+
if result.get("error"):
|
|
108
|
+
return {
|
|
109
|
+
"step": "pull_request",
|
|
110
|
+
"error": result["error"],
|
|
111
|
+
"pr_title": pr_title,
|
|
112
|
+
"events": [
|
|
113
|
+
{
|
|
114
|
+
"event": "pull_request_failed",
|
|
115
|
+
"timestamp": utc_now(),
|
|
116
|
+
"signals": {"error": result["error"]},
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"step": "pull_request",
|
|
123
|
+
"error": None,
|
|
124
|
+
"pr_created": True,
|
|
125
|
+
"pr_url": result.get("url", ""),
|
|
126
|
+
"pr_title": pr_title,
|
|
127
|
+
"events": [
|
|
128
|
+
{
|
|
129
|
+
"event": "pull_request_completed",
|
|
130
|
+
"timestamp": utc_now(),
|
|
131
|
+
"signals": {"pr_created": True, "pr_url": result.get("url", "")},
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _create_github_pr(title: str, description: str) -> dict[str, Any]:
|
|
138
|
+
"""Create a GitHub PR via agdt-gh-create-pull-request."""
|
|
139
|
+
set_result = run_command(["agdt-set", "target_branch", "main"])
|
|
140
|
+
if set_result.returncode != 0:
|
|
141
|
+
return {"error": f"Failed to set 'target_branch' state before PR creation: {set_result.stderr.strip()}"}
|
|
142
|
+
|
|
143
|
+
set_result = run_command(["agdt-set", "draft", "true"])
|
|
144
|
+
if set_result.returncode != 0:
|
|
145
|
+
return {"error": f"Failed to set 'draft' state before PR creation: {set_result.stderr.strip()}"}
|
|
146
|
+
|
|
147
|
+
result = run_command(
|
|
148
|
+
[
|
|
149
|
+
"agdt-gh-create-pull-request",
|
|
150
|
+
"--title",
|
|
151
|
+
title,
|
|
152
|
+
"--body",
|
|
153
|
+
description,
|
|
154
|
+
],
|
|
155
|
+
timeout=120,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if result.returncode != 0:
|
|
159
|
+
return {"error": f"GitHub PR creation failed: {result.stderr.strip()}"}
|
|
160
|
+
|
|
161
|
+
# Try to extract PR URL from output
|
|
162
|
+
url = ""
|
|
163
|
+
for line in result.stdout.splitlines():
|
|
164
|
+
match = re.search(r"https://github\.com/\S+/pull/\d+", line)
|
|
165
|
+
if match:
|
|
166
|
+
url = match.group(0)
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
return {"url": url}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _create_azure_devops_pr(title: str, description: str, source_branch: str) -> dict[str, Any]:
|
|
173
|
+
"""Create an Azure DevOps PR via agdt-create-pull-request."""
|
|
174
|
+
# Set state values for the command; any failure is a hard prerequisite error
|
|
175
|
+
set_cmds = [
|
|
176
|
+
["agdt-set", "source_branch", source_branch],
|
|
177
|
+
["agdt-set", "target_branch", "main"],
|
|
178
|
+
["agdt-set", "title", title],
|
|
179
|
+
["agdt-set", "description", description],
|
|
180
|
+
["agdt-set", "draft", "true"],
|
|
181
|
+
]
|
|
182
|
+
for cmd in set_cmds:
|
|
183
|
+
set_result = run_command(cmd)
|
|
184
|
+
if set_result.returncode != 0:
|
|
185
|
+
key = cmd[1]
|
|
186
|
+
return {"error": f"Failed to set '{key}' state before PR creation: {set_result.stderr.strip()}"}
|
|
187
|
+
|
|
188
|
+
result = run_command(["agdt-create-pull-request"], timeout=120)
|
|
189
|
+
if result.returncode != 0:
|
|
190
|
+
return {"error": f"Azure DevOps PR creation failed: {result.stderr.strip()}"}
|
|
191
|
+
|
|
192
|
+
# Wait for background task
|
|
193
|
+
wait_result = run_command(["agdt-task-wait"], timeout=120)
|
|
194
|
+
if wait_result.returncode != 0:
|
|
195
|
+
return {"error": f"PR creation task wait failed: {wait_result.stderr.strip()}"}
|
|
196
|
+
|
|
197
|
+
# Extract PR URL from task log output (agdt-create-pull-request prints "PR URL: <url>")
|
|
198
|
+
url = ""
|
|
199
|
+
log_result = run_command(["agdt-task-log"])
|
|
200
|
+
if log_result.returncode == 0:
|
|
201
|
+
for line in log_result.stdout.splitlines():
|
|
202
|
+
stripped = line.strip()
|
|
203
|
+
if stripped.startswith("PR URL: "):
|
|
204
|
+
url = stripped[len("PR URL: ") :]
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
return {"url": url}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _generate_pr_title(issue_key: str, issue_data: Any) -> str:
|
|
211
|
+
"""Generate PR title from issue data."""
|
|
212
|
+
summary = ""
|
|
213
|
+
if isinstance(issue_data, dict):
|
|
214
|
+
raw_summary = issue_data.get("summary", "")
|
|
215
|
+
if isinstance(raw_summary, str):
|
|
216
|
+
summary = raw_summary
|
|
217
|
+
|
|
218
|
+
if not summary:
|
|
219
|
+
summary = "autonomous implementation"
|
|
220
|
+
|
|
221
|
+
normalized = issue_key.lstrip("#")
|
|
222
|
+
if normalized.isdigit():
|
|
223
|
+
scope = f"#{normalized}"
|
|
224
|
+
else:
|
|
225
|
+
scope = normalized
|
|
226
|
+
|
|
227
|
+
title = f"feat({scope}): {summary}"
|
|
228
|
+
if len(title) > 72:
|
|
229
|
+
title = title[:69] + "..."
|
|
230
|
+
return title
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _generate_pr_description(issue_key: str, plan: str, issue_data: Any) -> str:
|
|
234
|
+
"""Generate PR description."""
|
|
235
|
+
summary = ""
|
|
236
|
+
if isinstance(issue_data, dict):
|
|
237
|
+
raw_summary = issue_data.get("summary", "")
|
|
238
|
+
if isinstance(raw_summary, str):
|
|
239
|
+
summary = raw_summary
|
|
240
|
+
|
|
241
|
+
if not isinstance(plan, str):
|
|
242
|
+
plan = ""
|
|
243
|
+
|
|
244
|
+
description_parts = [
|
|
245
|
+
f"## Summary\n\nAutonomous implementation for issue {issue_key}.",
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
if summary:
|
|
249
|
+
description_parts.append(f"\n**Issue**: {summary}")
|
|
250
|
+
|
|
251
|
+
if plan:
|
|
252
|
+
plan_preview = plan[:500]
|
|
253
|
+
description_parts.append(f"\n## Implementation Plan\n\n{plan_preview}")
|
|
254
|
+
|
|
255
|
+
description_parts.append("\n---\n*Generated by LangChain work-on-issue workflow*")
|
|
256
|
+
return "\n".join(description_parts)
|