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,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)