bugpilot 0.1.0__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.
- bugpilot/__init__.py +5 -0
- bugpilot/__main__.py +5 -0
- bugpilot/cli.py +2615 -0
- bugpilot/cli_json.py +173 -0
- bugpilot/core/__init__.py +1 -0
- bugpilot/core/agent_runner.py +138 -0
- bugpilot/core/artifact_io.py +40 -0
- bugpilot/core/artifacts.py +47 -0
- bugpilot/core/attachments.py +247 -0
- bugpilot/core/branch_policy.py +223 -0
- bugpilot/core/cleanup.py +58 -0
- bugpilot/core/code_files.py +99 -0
- bugpilot/core/config.py +242 -0
- bugpilot/core/context.py +436 -0
- bugpilot/core/copilot.py +47 -0
- bugpilot/core/delivery_instructions.py +106 -0
- bugpilot/core/doctor.py +66 -0
- bugpilot/core/email_notify.py +316 -0
- bugpilot/core/errors.py +105 -0
- bugpilot/core/executables.py +87 -0
- bugpilot/core/fix_mode_state.py +212 -0
- bugpilot/core/fix_mode_store.py +600 -0
- bugpilot/core/fix_modes.py +412 -0
- bugpilot/core/fix_report.py +124 -0
- bugpilot/core/git_history.py +1579 -0
- bugpilot/core/git_ops.py +254 -0
- bugpilot/core/handoff.py +127 -0
- bugpilot/core/identity.py +112 -0
- bugpilot/core/input_adapters.py +97 -0
- bugpilot/core/instructions.py +337 -0
- bugpilot/core/issue.py +487 -0
- bugpilot/core/jira.py +886 -0
- bugpilot/core/jira_adf.py +167 -0
- bugpilot/core/jira_parse.py +442 -0
- bugpilot/core/keywords.py +386 -0
- bugpilot/core/logging_utils.py +28 -0
- bugpilot/core/memory.py +151 -0
- bugpilot/core/models.py +322 -0
- bugpilot/core/project_settings.py +283 -0
- bugpilot/core/prompts.py +567 -0
- bugpilot/core/repository_profile.py +739 -0
- bugpilot/core/retrieval.py +485 -0
- bugpilot/core/review_changes.py +155 -0
- bugpilot/core/review_report.py +171 -0
- bugpilot/core/run.py +171 -0
- bugpilot/core/safe_paths.py +173 -0
- bugpilot/core/search.py +765 -0
- bugpilot/core/search_terms.py +310 -0
- bugpilot/core/setup.py +212 -0
- bugpilot/core/user_config.py +211 -0
- bugpilot/core/verification_report.py +313 -0
- bugpilot/core/workflow.py +2385 -0
- bugpilot/mcp_server.py +651 -0
- bugpilot-0.1.0.dist-info/METADATA +270 -0
- bugpilot-0.1.0.dist-info/RECORD +59 -0
- bugpilot-0.1.0.dist-info/WHEEL +5 -0
- bugpilot-0.1.0.dist-info/entry_points.txt +3 -0
- bugpilot-0.1.0.dist-info/licenses/LICENSE +122 -0
- bugpilot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Build and send the post-fix notification email.
|
|
2
|
+
|
|
3
|
+
The email is assembled entirely from local bugpilot artifacts (fix_report.md
|
|
4
|
+
and issue.json) and sent over SMTP using credentials supplied through the
|
|
5
|
+
environment. bugpilot never hardcodes SMTP secrets and never commits, pushes, or
|
|
6
|
+
updates Jira as part of sending this notification.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import smtplib
|
|
14
|
+
import ssl
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from email.generator import BytesGenerator
|
|
19
|
+
from email.message import EmailMessage
|
|
20
|
+
from email.utils import formataddr, parseaddr
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from urllib.parse import urlencode
|
|
23
|
+
|
|
24
|
+
from .config import EmailConfig, GraphConfig, issue_dir, load_config
|
|
25
|
+
from .fix_report import read_fix_report
|
|
26
|
+
from .issue import read_issue_quietly
|
|
27
|
+
from .jira import JiraSiteError, normalize_jira_site, sanitize_comment_text
|
|
28
|
+
from .models import SOURCE_MANUAL
|
|
29
|
+
|
|
30
|
+
GRAPH_TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
|
31
|
+
GRAPH_SENDMAIL_URL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
|
|
32
|
+
GRAPH_SCOPE = "https://graph.microsoft.com/.default"
|
|
33
|
+
|
|
34
|
+
MAX_SECTION_CHARS = 4000
|
|
35
|
+
MAX_BODY_CHARS = 60000
|
|
36
|
+
_NOT_AVAILABLE = "Not available in local artifacts."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class EmailDraft:
|
|
41
|
+
issue_key: str
|
|
42
|
+
subject: str
|
|
43
|
+
body: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class EmailSendResult:
|
|
48
|
+
issue_key: str
|
|
49
|
+
sent: bool
|
|
50
|
+
recipients: tuple[str, ...]
|
|
51
|
+
skipped_reason: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EmailSendError(Exception):
|
|
55
|
+
"""Raised when an SMTP send fails. The message never contains credentials."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_email_draft(repo_root: Path, issue_key: str) -> EmailDraft:
|
|
59
|
+
report = read_fix_report(repo_root, issue_key)
|
|
60
|
+
|
|
61
|
+
# The title and the original problem come from the normalized issue, which a
|
|
62
|
+
# Jira work item and a hand-written one share. Without them the notification
|
|
63
|
+
# says "not available" for the one thing the reader most needs.
|
|
64
|
+
issue = read_issue_quietly(repo_root, issue_key)
|
|
65
|
+
is_manual = issue is not None and issue.source == SOURCE_MANUAL
|
|
66
|
+
|
|
67
|
+
summary_line = (issue.title if issue else "") or (report.summary if report else "")
|
|
68
|
+
summary_line = _first_meaningful_line(summary_line)
|
|
69
|
+
|
|
70
|
+
original_problem = (issue.description if issue else "") or _NOT_AVAILABLE
|
|
71
|
+
root_cause = (report.analysis if report else "") or _NOT_AVAILABLE
|
|
72
|
+
changes = (report.changes if report else "") or _NOT_AVAILABLE
|
|
73
|
+
test_summary = (report.tests if report else "") or _NOT_AVAILABLE
|
|
74
|
+
|
|
75
|
+
subject = f"[bugpilot] {issue_key} fix ready to commit"
|
|
76
|
+
if summary_line:
|
|
77
|
+
subject = f"{subject}: {summary_line[:80]}"
|
|
78
|
+
|
|
79
|
+
link_line = "" if is_manual else _jira_link(repo_root, issue_key)
|
|
80
|
+
item_heading = "Work Item" if is_manual else "Jira Item"
|
|
81
|
+
body = (
|
|
82
|
+
"bugpilot bug fix notification\n"
|
|
83
|
+
"===========================\n\n"
|
|
84
|
+
"Automated notification generated at the commit decision point.\n"
|
|
85
|
+
"Review before committing. bugpilot did NOT commit, push, merge, open a PR,\n"
|
|
86
|
+
"or update Jira.\n\n"
|
|
87
|
+
f"## {item_heading}\n"
|
|
88
|
+
f"{issue_key}" + (f" — {summary_line}" if summary_line else "") + "\n"
|
|
89
|
+
+ (f"{link_line}\n" if link_line else "")
|
|
90
|
+
+ "\n"
|
|
91
|
+
"## Original Problem\n"
|
|
92
|
+
f"{_cap(original_problem)}\n\n"
|
|
93
|
+
"## Bug Cause / Root Cause\n"
|
|
94
|
+
f"{_cap(root_cause)}\n\n"
|
|
95
|
+
"## Changes Made\n"
|
|
96
|
+
f"{_cap(changes)}\n\n"
|
|
97
|
+
"## Tests\n"
|
|
98
|
+
f"{_cap(test_summary)}\n\n"
|
|
99
|
+
"--\n"
|
|
100
|
+
f"Generated by bugpilot from local .ai/{issue_key}/ artifacts.\n"
|
|
101
|
+
)
|
|
102
|
+
body = _cap(sanitize_comment_text(body), MAX_BODY_CHARS)
|
|
103
|
+
return EmailDraft(issue_key=issue_key, subject=subject, body=body)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def send_notification(config: EmailConfig, draft: EmailDraft) -> EmailSendResult:
|
|
107
|
+
if not config.is_configured:
|
|
108
|
+
missing = ", ".join(config.missing_fields())
|
|
109
|
+
return EmailSendResult(
|
|
110
|
+
issue_key=draft.issue_key,
|
|
111
|
+
sent=False,
|
|
112
|
+
recipients=config.recipients,
|
|
113
|
+
skipped_reason=f"Email not configured. Missing: {missing}.",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
invalid = [addr for addr in config.recipients if not _looks_like_email(addr)]
|
|
117
|
+
if invalid or not _looks_like_email(config.sender or ""):
|
|
118
|
+
bad = ", ".join(invalid or [config.sender or "(empty)"])
|
|
119
|
+
raise EmailSendError(f"Invalid email address configured: {bad}")
|
|
120
|
+
|
|
121
|
+
message = _build_message(config, draft)
|
|
122
|
+
try:
|
|
123
|
+
_deliver(config, message)
|
|
124
|
+
except (smtplib.SMTPException, OSError) as exc:
|
|
125
|
+
# Surface the error class/reason, never the credentials.
|
|
126
|
+
raise EmailSendError(f"SMTP send failed: {type(exc).__name__}: {exc}") from exc
|
|
127
|
+
|
|
128
|
+
return EmailSendResult(
|
|
129
|
+
issue_key=draft.issue_key,
|
|
130
|
+
sent=True,
|
|
131
|
+
recipients=config.recipients,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def render_eml(draft: EmailDraft, sender: str | None = None, recipients: tuple[str, ...] = ()) -> bytes:
|
|
136
|
+
"""Render the draft as a standalone RFC 822 .eml file.
|
|
137
|
+
|
|
138
|
+
Works without SMTP configuration: From/To headers are filled in only when
|
|
139
|
+
available so the file can be opened in Outlook (which sends over its own
|
|
140
|
+
modern-auth channel, sidestepping SMTP entirely).
|
|
141
|
+
"""
|
|
142
|
+
message = _message(draft, sender, recipients)
|
|
143
|
+
buffer = io.BytesIO()
|
|
144
|
+
BytesGenerator(buffer, policy=message.policy).flatten(message)
|
|
145
|
+
return buffer.getvalue()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def send_via_graph(
|
|
149
|
+
graph_config: GraphConfig,
|
|
150
|
+
draft: EmailDraft,
|
|
151
|
+
sender: str | None,
|
|
152
|
+
recipients: tuple[str, ...],
|
|
153
|
+
) -> EmailSendResult:
|
|
154
|
+
"""Send the notification through Microsoft Graph (client-credentials flow).
|
|
155
|
+
|
|
156
|
+
Uses HTTPS only, so it works when the tenant disables SMTP client auth and
|
|
157
|
+
when outbound port 25 is blocked. Requires an app registration with the
|
|
158
|
+
application permission Mail.Send (admin-consented).
|
|
159
|
+
"""
|
|
160
|
+
if not graph_config.is_configured or not sender or not recipients:
|
|
161
|
+
missing = ", ".join(graph_config.missing_fields())
|
|
162
|
+
if not sender:
|
|
163
|
+
missing = (missing + ", BUGPILOT_EMAIL_FROM").lstrip(", ")
|
|
164
|
+
if not recipients:
|
|
165
|
+
missing = (missing + ", BUGPILOT_EMAIL_TO").lstrip(", ")
|
|
166
|
+
return EmailSendResult(
|
|
167
|
+
issue_key=draft.issue_key,
|
|
168
|
+
sent=False,
|
|
169
|
+
recipients=recipients,
|
|
170
|
+
skipped_reason=f"Graph email not configured. Missing: {missing}.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
invalid = [addr for addr in recipients if not _looks_like_email(addr)]
|
|
174
|
+
if invalid or not _looks_like_email(sender):
|
|
175
|
+
bad = ", ".join(invalid or [sender])
|
|
176
|
+
raise EmailSendError(f"Invalid email address configured: {bad}")
|
|
177
|
+
|
|
178
|
+
token = _graph_token(graph_config)
|
|
179
|
+
_graph_send_mail(token, sender, recipients, draft)
|
|
180
|
+
return EmailSendResult(issue_key=draft.issue_key, sent=True, recipients=recipients)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _graph_token(graph_config: GraphConfig) -> str:
|
|
184
|
+
data = urlencode(
|
|
185
|
+
{
|
|
186
|
+
"client_id": graph_config.client_id,
|
|
187
|
+
"client_secret": graph_config.client_secret,
|
|
188
|
+
"scope": GRAPH_SCOPE,
|
|
189
|
+
"grant_type": "client_credentials",
|
|
190
|
+
}
|
|
191
|
+
).encode("utf-8")
|
|
192
|
+
request = urllib.request.Request(
|
|
193
|
+
GRAPH_TOKEN_URL.format(tenant=graph_config.tenant_id),
|
|
194
|
+
data=data,
|
|
195
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
196
|
+
method="POST",
|
|
197
|
+
)
|
|
198
|
+
try:
|
|
199
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
200
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
201
|
+
except urllib.error.HTTPError as exc:
|
|
202
|
+
# Do not echo the request body (it carries the client secret).
|
|
203
|
+
raise EmailSendError(f"Graph token request failed: HTTP {exc.code}") from exc
|
|
204
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
205
|
+
raise EmailSendError(f"Graph token request failed: {type(exc).__name__}: {exc}") from exc
|
|
206
|
+
token = payload.get("access_token") if isinstance(payload, dict) else None
|
|
207
|
+
if not token:
|
|
208
|
+
raise EmailSendError("Graph token response did not contain an access token.")
|
|
209
|
+
return str(token)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _graph_send_mail(token: str, sender: str, recipients: tuple[str, ...], draft: EmailDraft) -> None:
|
|
213
|
+
body = json.dumps(
|
|
214
|
+
{
|
|
215
|
+
"message": {
|
|
216
|
+
"subject": draft.subject,
|
|
217
|
+
"body": {"contentType": "Text", "content": draft.body},
|
|
218
|
+
"toRecipients": [{"emailAddress": {"address": addr}} for addr in recipients],
|
|
219
|
+
},
|
|
220
|
+
"saveToSentItems": True,
|
|
221
|
+
}
|
|
222
|
+
).encode("utf-8")
|
|
223
|
+
request = urllib.request.Request(
|
|
224
|
+
GRAPH_SENDMAIL_URL.format(sender=sender),
|
|
225
|
+
data=body,
|
|
226
|
+
headers={
|
|
227
|
+
"Authorization": f"Bearer {token}",
|
|
228
|
+
"Content-Type": "application/json",
|
|
229
|
+
},
|
|
230
|
+
method="POST",
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
234
|
+
response.read()
|
|
235
|
+
except urllib.error.HTTPError as exc:
|
|
236
|
+
detail = _graph_error_detail(exc)
|
|
237
|
+
raise EmailSendError(f"Graph sendMail failed: HTTP {exc.code}{detail}") from exc
|
|
238
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
239
|
+
raise EmailSendError(f"Graph sendMail failed: {type(exc).__name__}: {exc}") from exc
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _graph_error_detail(exc: urllib.error.HTTPError) -> str:
|
|
243
|
+
try:
|
|
244
|
+
payload = json.loads(exc.read().decode("utf-8"))
|
|
245
|
+
code = payload.get("error", {}).get("code")
|
|
246
|
+
return f" ({code})" if code else ""
|
|
247
|
+
except Exception:
|
|
248
|
+
return ""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _build_message(config: EmailConfig, draft: EmailDraft) -> EmailMessage:
|
|
252
|
+
return _message(draft, config.sender, config.recipients)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _message(draft: EmailDraft, sender: str | None, recipients: tuple[str, ...]) -> EmailMessage:
|
|
256
|
+
message = EmailMessage()
|
|
257
|
+
message["Subject"] = draft.subject
|
|
258
|
+
if sender:
|
|
259
|
+
message["From"] = formataddr(("bugpilot", sender))
|
|
260
|
+
if recipients:
|
|
261
|
+
message["To"] = ", ".join(recipients)
|
|
262
|
+
message.set_content(draft.body)
|
|
263
|
+
return message
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _deliver(config: EmailConfig, message: EmailMessage) -> None:
|
|
267
|
+
host = config.host or ""
|
|
268
|
+
if config.use_ssl:
|
|
269
|
+
context = ssl.create_default_context()
|
|
270
|
+
with smtplib.SMTP_SSL(host, config.port, timeout=30, context=context) as server:
|
|
271
|
+
_authenticate_and_send(server, config, message)
|
|
272
|
+
return
|
|
273
|
+
with smtplib.SMTP(host, config.port, timeout=30) as server:
|
|
274
|
+
if config.use_starttls:
|
|
275
|
+
server.starttls(context=ssl.create_default_context())
|
|
276
|
+
_authenticate_and_send(server, config, message)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _authenticate_and_send(server: smtplib.SMTP, config: EmailConfig, message: EmailMessage) -> None:
|
|
280
|
+
if config.uses_auth:
|
|
281
|
+
server.login(config.username or "", config.password or "")
|
|
282
|
+
server.send_message(message)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _jira_link(repo_root: Path, issue_key: str) -> str:
|
|
286
|
+
base_url = load_config(repo_root).jira_base_url
|
|
287
|
+
if not base_url:
|
|
288
|
+
return ""
|
|
289
|
+
# The site Jira requests accept, or no link: an http:// or credential-bearing
|
|
290
|
+
# value never reaches a recipient's mailbox.
|
|
291
|
+
try:
|
|
292
|
+
site = normalize_jira_site(base_url)
|
|
293
|
+
except JiraSiteError:
|
|
294
|
+
return ""
|
|
295
|
+
return f"Link: {site}/browse/{issue_key}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _first_meaningful_line(text: str) -> str:
|
|
299
|
+
for line in text.splitlines():
|
|
300
|
+
stripped = line.strip()
|
|
301
|
+
if stripped and stripped != "TBD":
|
|
302
|
+
return stripped
|
|
303
|
+
return ""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _cap(text: str, limit: int = MAX_SECTION_CHARS) -> str:
|
|
307
|
+
text = text.strip() or _NOT_AVAILABLE
|
|
308
|
+
if len(text) <= limit:
|
|
309
|
+
return text
|
|
310
|
+
return text[:limit].rstrip() + "\n\n[truncated by bugpilot]"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _looks_like_email(address: str) -> bool:
|
|
314
|
+
name, addr = parseaddr(address)
|
|
315
|
+
del name
|
|
316
|
+
return "@" in addr and "." in addr.split("@")[-1] and " " not in addr
|
bugpilot/core/errors.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Stable error codes shared by every entry point.
|
|
2
|
+
|
|
3
|
+
The codes are the machine-facing half of a failure; the human message is the
|
|
4
|
+
other half and may be reworded freely. A VS Code extension branches on
|
|
5
|
+
``error.code`` and never parses the message — see ``docs/adapter_design.md``
|
|
6
|
+
section 5.1.
|
|
7
|
+
|
|
8
|
+
A code says *what went wrong*. The command being run is reported separately, so
|
|
9
|
+
the same JIRA_AUTH_FAILED covers a failed fetch and a failed comment post rather
|
|
10
|
+
than needing one code per operation.
|
|
11
|
+
|
|
12
|
+
Adding a code is a contract change: append, never rename or repurpose.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
# --- Jira -------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
JIRA_NOT_CONFIGURED = "JIRA_NOT_CONFIGURED"
|
|
20
|
+
JIRA_AUTH_FAILED = "JIRA_AUTH_FAILED"
|
|
21
|
+
JIRA_ISSUE_NOT_FOUND = "JIRA_ISSUE_NOT_FOUND"
|
|
22
|
+
JIRA_RATE_LIMITED = "JIRA_RATE_LIMITED"
|
|
23
|
+
JIRA_TIMEOUT = "JIRA_TIMEOUT"
|
|
24
|
+
JIRA_NETWORK_ERROR = "JIRA_NETWORK_ERROR"
|
|
25
|
+
JIRA_INVALID_RESPONSE = "JIRA_INVALID_RESPONSE"
|
|
26
|
+
JIRA_ERROR = "JIRA_ERROR"
|
|
27
|
+
# The configured Jira site is not an https:// address BugPilot will send
|
|
28
|
+
# credentials to.
|
|
29
|
+
JIRA_INVALID_SITE = "JIRA_INVALID_SITE"
|
|
30
|
+
# Jira redirected to another origin, and the credentials were not sent there.
|
|
31
|
+
JIRA_REDIRECT_REFUSED = "JIRA_REDIRECT_REFUSED"
|
|
32
|
+
|
|
33
|
+
# --- source-mode gates (design section 5.1) ---------------------------------
|
|
34
|
+
|
|
35
|
+
# The command only makes sense for a Jira work item (fetch, jira-validate, parse).
|
|
36
|
+
JIRA_ONLY_COMMAND = "JIRA_ONLY_COMMAND"
|
|
37
|
+
# There is no external issue to write back to: a manual work item has no source_ref.
|
|
38
|
+
NO_JIRA_TARGET = "NO_JIRA_TARGET"
|
|
39
|
+
|
|
40
|
+
# --- local state ------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
WORK_ITEM_NOT_FOUND = "WORK_ITEM_NOT_FOUND"
|
|
43
|
+
ARTIFACT_NOT_FOUND = "ARTIFACT_NOT_FOUND"
|
|
44
|
+
MISSING_RESULTS = "MISSING_RESULTS"
|
|
45
|
+
INVALID_INPUT = "INVALID_INPUT"
|
|
46
|
+
# The artifact a command would write is already there, and the command keeps it
|
|
47
|
+
# unless asked to replace it (record-review or record-verification without
|
|
48
|
+
# --replace).
|
|
49
|
+
ARTIFACT_EXISTS = "ARTIFACT_EXISTS"
|
|
50
|
+
|
|
51
|
+
# --- other ------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
EMAIL_SEND_FAILED = "EMAIL_SEND_FAILED"
|
|
54
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
55
|
+
|
|
56
|
+
# jira.ERROR_MESSAGES keys -> codes. That vocabulary already exists and is
|
|
57
|
+
# stable, so the mapping is a rename rather than a second classification.
|
|
58
|
+
_JIRA_ERROR_TYPES: dict[str, str] = {
|
|
59
|
+
"missing_env": JIRA_NOT_CONFIGURED,
|
|
60
|
+
"auth_or_permission": JIRA_AUTH_FAILED,
|
|
61
|
+
"not_found": JIRA_ISSUE_NOT_FOUND,
|
|
62
|
+
"rate_limited": JIRA_RATE_LIMITED,
|
|
63
|
+
"timeout": JIRA_TIMEOUT,
|
|
64
|
+
"network_error": JIRA_NETWORK_ERROR,
|
|
65
|
+
"invalid_response": JIRA_INVALID_RESPONSE,
|
|
66
|
+
"unknown_error": JIRA_ERROR,
|
|
67
|
+
"invalid_site": JIRA_INVALID_SITE,
|
|
68
|
+
"redirect_refused": JIRA_REDIRECT_REFUSED,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def code_for_jira_error_type(error_type: str | None) -> str:
|
|
73
|
+
"""Map a ``jira.ERROR_MESSAGES`` key to its stable code."""
|
|
74
|
+
return _JIRA_ERROR_TYPES.get(error_type or "", JIRA_ERROR)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def error_code_for(exc: BaseException) -> str:
|
|
78
|
+
"""Classify an exception for the machine-facing envelope.
|
|
79
|
+
|
|
80
|
+
Imports are local so this module stays a leaf: every adapter needs the code
|
|
81
|
+
vocabulary, and a top-level import of ``jira`` would drag its network stack
|
|
82
|
+
into anything that only wants to name a code.
|
|
83
|
+
"""
|
|
84
|
+
from .email_notify import EmailSendError
|
|
85
|
+
from .jira import JiraCommentPostError, JiraFetchError
|
|
86
|
+
|
|
87
|
+
if isinstance(exc, JiraFetchError):
|
|
88
|
+
return code_for_jira_error_type(getattr(exc.result, "error_type", None))
|
|
89
|
+
if isinstance(exc, JiraCommentPostError):
|
|
90
|
+
return code_for_jira_error_type(getattr(exc, "error_type", None))
|
|
91
|
+
if isinstance(exc, EmailSendError):
|
|
92
|
+
return EMAIL_SEND_FAILED
|
|
93
|
+
from .run import RunArtifactError
|
|
94
|
+
|
|
95
|
+
if isinstance(exc, FileNotFoundError):
|
|
96
|
+
return ARTIFACT_NOT_FOUND
|
|
97
|
+
if isinstance(exc, FileExistsError):
|
|
98
|
+
return ARTIFACT_EXISTS
|
|
99
|
+
# Before the ValueError arm it subclasses: an unusable run.json is a state
|
|
100
|
+
# problem the caller fixes by re-preparing, not a bad argument.
|
|
101
|
+
if isinstance(exc, RunArtifactError):
|
|
102
|
+
return ARTIFACT_NOT_FOUND
|
|
103
|
+
if isinstance(exc, ValueError):
|
|
104
|
+
return INVALID_INPUT
|
|
105
|
+
return INTERNAL_ERROR
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Finding external programs without trusting the current directory.
|
|
2
|
+
|
|
3
|
+
BugPilot runs in the repository it is preparing: that directory is the process's
|
|
4
|
+
current directory, and every ``git`` and ``rg`` call passes it as ``cwd``. On
|
|
5
|
+
Windows a bare program name is looked up in the current directory *before*
|
|
6
|
+
``PATH`` — by ``CreateProcess`` and by ``shutil.which`` alike, unless the
|
|
7
|
+
``NoDefaultCurrentDirectoryInExePath`` variable happens to be set — so a
|
|
8
|
+
``git.exe`` committed to a repository would run instead of Git the moment
|
|
9
|
+
BugPilot touched it.
|
|
10
|
+
|
|
11
|
+
So no program is started by its bare name. :func:`find_executable` resolves a
|
|
12
|
+
name to an absolute path using only the absolute entries of ``PATH``, in order,
|
|
13
|
+
with ``PATHEXT`` on Windows; it never consults the current directory, and it
|
|
14
|
+
skips empty, ``.`` and other relative ``PATH`` entries, which mean the same
|
|
15
|
+
thing. Callers start the absolute path it returns.
|
|
16
|
+
|
|
17
|
+
The VS Code extension applies the same policy to the programs it starts
|
|
18
|
+
(``extension/src/executablePath.ts``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
_DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_windows() -> bool:
|
|
31
|
+
return sys.platform == "win32"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _runnable(candidate: Path) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
if not candidate.is_file():
|
|
37
|
+
return False
|
|
38
|
+
except OSError:
|
|
39
|
+
return False
|
|
40
|
+
return True if _is_windows() else os.access(candidate, os.X_OK)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_executable(name: str, path: str | None = None) -> str | None:
|
|
44
|
+
"""The absolute path ``name`` resolves to, or ``None``.
|
|
45
|
+
|
|
46
|
+
- An absolute path is returned as it is, if it names a runnable file.
|
|
47
|
+
- A relative path with a directory part (``tools/rg``) is refused: it would
|
|
48
|
+
resolve against the repository.
|
|
49
|
+
- A bare name is searched in the absolute entries of ``path`` (default: the
|
|
50
|
+
``PATH`` environment variable), never in the current directory.
|
|
51
|
+
"""
|
|
52
|
+
if not name:
|
|
53
|
+
return None
|
|
54
|
+
given = Path(name)
|
|
55
|
+
if given.is_absolute():
|
|
56
|
+
return str(given) if _runnable(given) else None
|
|
57
|
+
if os.sep in name or (os.altsep and os.altsep in name) or "/" in name:
|
|
58
|
+
return None
|
|
59
|
+
search = os.environ.get("PATH", "") if path is None else path
|
|
60
|
+
if _is_windows():
|
|
61
|
+
extensions = [ext for ext in (os.environ.get("PATHEXT") or _DEFAULT_PATHEXT).split(";") if ext]
|
|
62
|
+
has_extension = any(name.lower().endswith(ext.lower()) for ext in extensions)
|
|
63
|
+
names = [name] if has_extension else [name + ext for ext in extensions]
|
|
64
|
+
else:
|
|
65
|
+
names = [name]
|
|
66
|
+
for entry in search.split(os.pathsep):
|
|
67
|
+
entry = entry.strip().strip('"')
|
|
68
|
+
if not entry or not os.path.isabs(entry):
|
|
69
|
+
continue
|
|
70
|
+
for candidate_name in names:
|
|
71
|
+
candidate = Path(entry) / candidate_name
|
|
72
|
+
if _runnable(candidate):
|
|
73
|
+
return str(candidate)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def child_environment(base: dict[str, str] | None = None) -> dict[str, str]:
|
|
78
|
+
"""An environment for a child that starts programs of its own.
|
|
79
|
+
|
|
80
|
+
On Windows, ``NoDefaultCurrentDirectoryInExePath`` makes ``cmd.exe`` and
|
|
81
|
+
``CreateProcess`` callers inside the child skip the current directory too —
|
|
82
|
+
an npm ``claude.cmd`` shim, for one, looks up a bare ``node``.
|
|
83
|
+
"""
|
|
84
|
+
env = dict(os.environ if base is None else base)
|
|
85
|
+
if _is_windows():
|
|
86
|
+
env["NoDefaultCurrentDirectoryInExePath"] = "1"
|
|
87
|
+
return env
|