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.
Files changed (59) hide show
  1. bugpilot/__init__.py +5 -0
  2. bugpilot/__main__.py +5 -0
  3. bugpilot/cli.py +2615 -0
  4. bugpilot/cli_json.py +173 -0
  5. bugpilot/core/__init__.py +1 -0
  6. bugpilot/core/agent_runner.py +138 -0
  7. bugpilot/core/artifact_io.py +40 -0
  8. bugpilot/core/artifacts.py +47 -0
  9. bugpilot/core/attachments.py +247 -0
  10. bugpilot/core/branch_policy.py +223 -0
  11. bugpilot/core/cleanup.py +58 -0
  12. bugpilot/core/code_files.py +99 -0
  13. bugpilot/core/config.py +242 -0
  14. bugpilot/core/context.py +436 -0
  15. bugpilot/core/copilot.py +47 -0
  16. bugpilot/core/delivery_instructions.py +106 -0
  17. bugpilot/core/doctor.py +66 -0
  18. bugpilot/core/email_notify.py +316 -0
  19. bugpilot/core/errors.py +105 -0
  20. bugpilot/core/executables.py +87 -0
  21. bugpilot/core/fix_mode_state.py +212 -0
  22. bugpilot/core/fix_mode_store.py +600 -0
  23. bugpilot/core/fix_modes.py +412 -0
  24. bugpilot/core/fix_report.py +124 -0
  25. bugpilot/core/git_history.py +1579 -0
  26. bugpilot/core/git_ops.py +254 -0
  27. bugpilot/core/handoff.py +127 -0
  28. bugpilot/core/identity.py +112 -0
  29. bugpilot/core/input_adapters.py +97 -0
  30. bugpilot/core/instructions.py +337 -0
  31. bugpilot/core/issue.py +487 -0
  32. bugpilot/core/jira.py +886 -0
  33. bugpilot/core/jira_adf.py +167 -0
  34. bugpilot/core/jira_parse.py +442 -0
  35. bugpilot/core/keywords.py +386 -0
  36. bugpilot/core/logging_utils.py +28 -0
  37. bugpilot/core/memory.py +151 -0
  38. bugpilot/core/models.py +322 -0
  39. bugpilot/core/project_settings.py +283 -0
  40. bugpilot/core/prompts.py +567 -0
  41. bugpilot/core/repository_profile.py +739 -0
  42. bugpilot/core/retrieval.py +485 -0
  43. bugpilot/core/review_changes.py +155 -0
  44. bugpilot/core/review_report.py +171 -0
  45. bugpilot/core/run.py +171 -0
  46. bugpilot/core/safe_paths.py +173 -0
  47. bugpilot/core/search.py +765 -0
  48. bugpilot/core/search_terms.py +310 -0
  49. bugpilot/core/setup.py +212 -0
  50. bugpilot/core/user_config.py +211 -0
  51. bugpilot/core/verification_report.py +313 -0
  52. bugpilot/core/workflow.py +2385 -0
  53. bugpilot/mcp_server.py +651 -0
  54. bugpilot-0.1.0.dist-info/METADATA +270 -0
  55. bugpilot-0.1.0.dist-info/RECORD +59 -0
  56. bugpilot-0.1.0.dist-info/WHEEL +5 -0
  57. bugpilot-0.1.0.dist-info/entry_points.txt +3 -0
  58. bugpilot-0.1.0.dist-info/licenses/LICENSE +122 -0
  59. bugpilot-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,436 @@
1
+ """Build the context an agent reads first: ``context.md``.
2
+
3
+ One document from four inputs held in memory — the normalized issue, the
4
+ retrieval, and the git-history and similar-fixes results — so nothing is read
5
+ back from an intermediate file. It used to be assembled by pasting whole
6
+ sub-documents together, which left three `## Issue` headings and two
7
+ `## Status` headings in one file; each input now owns one section, and the
8
+ sub-documents' own headings sit one level below it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .artifacts import RETRIEVAL_ARTIFACT, TASK_ARTIFACT
14
+ from .git_history import GitHistoryOutcome, render_git_context
15
+ from .issue import IssueArtifact
16
+ from .jira import COMMENT_SIGNAL_TERMS
17
+ from .retrieval import RelatedFile, RetrievalArtifact
18
+
19
+
20
+ def build_context(
21
+ issue: IssueArtifact,
22
+ keywords: dict[str, object],
23
+ retrieval: RetrievalArtifact | None,
24
+ git_history: GitHistoryOutcome | None = None,
25
+ similar_fixes: str | None = None,
26
+ ) -> str:
27
+ """The context document.
28
+
29
+ ``retrieval`` is ``None`` when the search did not run, ``git_history`` and
30
+ ``similar_fixes`` when those steps did not; each dependent section then says
31
+ so instead of guessing. The two text results are what
32
+ Git history arrives as the structured outcome the Git history step
33
+ produced — the same record it wrote into ``retrieval.json`` — and is rendered
34
+ by ``git_history.render_git_context``, the one renderer of the commit list;
35
+ similar fixes as the Markdown ``memory.search_memory`` returns.
36
+ """
37
+ quality, signals = _quality_score(issue, keywords, retrieval, similar_fixes or "", git_history)
38
+ return (
39
+ f"# Bug Context: {issue.id}\n\n"
40
+ "## Scope\n\n"
41
+ "BugPilot prepares context only. It does not modify source code, invoke an agent automatically, "
42
+ "create commits, push branches, update Jira, or create pull requests.\n\n"
43
+ "## Issue\n\n"
44
+ f"- ID: {issue.id}\n"
45
+ f"- Source: {'Jira issue' if issue.is_jira else 'Hand-written description'}\n"
46
+ f"- Summary: {issue.title}\n"
47
+ f"- Type: {issue.details.issue_type}\n"
48
+ f"- Status: {issue.details.status}\n"
49
+ f"- Priority: {issue.details.priority}\n"
50
+ f"- Mock/demo data: {issue.details.mock}\n\n"
51
+ + _caution_markdown(issue)
52
+ + "## Guidance\n\n"
53
+ f"{_guidance_markdown(issue)}\n\n"
54
+ "## Context Quality\n\n"
55
+ f"Score: {quality}/100\n\n"
56
+ + "\n".join(f"- {signal}" for signal in signals)
57
+ + "\n\n"
58
+ "## Issue Details\n\n"
59
+ f"{_issue_details_markdown(issue)}\n\n"
60
+ "## Code Search\n\n"
61
+ "### Search Quality\n\n"
62
+ f"{_search_quality_markdown(retrieval)}\n\n"
63
+ "Agent instruction: If search confidence is Low, do not assume the matched files are the correct implementation. "
64
+ "Treat them as candidates only and first verify whether the feature exists in the codebase.\n\n"
65
+ "### Search Terms\n\n"
66
+ f"{_search_terms_markdown(keywords)}\n\n"
67
+ "### Relevant Files\n\n"
68
+ f"See `.ai/{issue.id}/{RETRIEVAL_ARTIFACT}` for the ranked files, their matched lines "
69
+ "and the search terms.\n\n"
70
+ f"{_relevant_files_markdown(retrieval)}\n\n"
71
+ "### Relevant Snippets\n\n"
72
+ f"{_matched_lines_excerpt(retrieval)}\n\n"
73
+ "## Similar Fixes\n\n"
74
+ f"{_similar_fixes_markdown(similar_fixes)}\n\n"
75
+ "## Git History\n\n"
76
+ f"{_git_history_markdown(git_history)}\n"
77
+ )
78
+
79
+
80
+ def _guidance_markdown(issue: IssueArtifact) -> str:
81
+ """The hint and Fix Mode this work item runs under, from ``issue.json``.
82
+
83
+ Only what they are: how the mode tells the agent to work is ``task.md``'s
84
+ business, and repeating it here would be a second copy to drift.
85
+ """
86
+ # On one line: the hint comes from a textarea, and a raw newline here would
87
+ # end the bullet — a line starting with `#` would even become a heading. The
88
+ # full text, line breaks kept, is in `task.md`'s Developer Hint section.
89
+ hint = " ".join((issue.guidance.hint or "").split()) or "_None._"
90
+ record = issue.guidance.fix_mode or {}
91
+ mode_id = record.get("id")
92
+ mode = f"{record.get('name') or mode_id} (`{mode_id}`)" if mode_id else "_Not recorded._"
93
+ return (
94
+ f"- Developer hint: {hint}\n"
95
+ f"- AI Fix Mode: {mode}. `{TASK_ARTIFACT}` carries what it asks of the agent."
96
+ )
97
+
98
+
99
+ def _search_terms_markdown(keywords: dict[str, object]) -> str:
100
+ def listed(key: str) -> str:
101
+ values = keywords.get(key)
102
+ return ", ".join(map(str, values)) if isinstance(values, list) and values else "_None_"
103
+
104
+ return (
105
+ f"- High value: {listed('high_value_keywords')}\n"
106
+ f"- Normal: {listed('normal_keywords')}\n"
107
+ f"- Phrases: {listed('phrase_keywords')}"
108
+ )
109
+
110
+
111
+ def _demote(markdown: str) -> str:
112
+ """A sub-document's headings, one level down and without its title.
113
+
114
+ So a section's own `##` headings sit under the `##` it is placed in rather
115
+ than beside it — the seam that used to leave two `## Status` headings in one
116
+ document.
117
+ """
118
+ lines = []
119
+ for line in markdown.splitlines():
120
+ if line.startswith("# "):
121
+ continue
122
+ lines.append("#" + line if line.startswith("#") else line)
123
+ return "\n".join(lines).strip()
124
+
125
+
126
+ _NOT_SPECIFIED = "Not specified."
127
+ # The newest comments are the ones most likely to change the picture.
128
+ COMMENT_RENDER_LIMIT = 10
129
+
130
+
131
+ def _jira_fields_markdown(issue: IssueArtifact) -> str:
132
+ """The Jira fields no other section shows. People's names are not kept."""
133
+ details = issue.details
134
+
135
+ def joined(values: tuple[str, ...]) -> str:
136
+ return ", ".join(values) or "None."
137
+
138
+ return (
139
+ f"- Data source: {'mock/demo fallback' if details.mock else 'jira'}\n"
140
+ f"- Mock/demo Jira data: {'yes' if details.mock else 'no'}\n"
141
+ f"- Resolution: {details.resolution or _NOT_SPECIFIED}\n"
142
+ f"- Labels: {joined(details.labels)}\n"
143
+ f"- Components: {joined(details.components)}\n"
144
+ f"- Affected versions: {joined(details.affected_versions)}\n"
145
+ f"- Fix versions: {joined(details.fix_versions)}"
146
+ )
147
+
148
+
149
+ def _comments_markdown(issue: IssueArtifact) -> str:
150
+ if not issue.comments:
151
+ return "No comments found."
152
+ total = len(issue.comments)
153
+ visible = issue.comments[-COMMENT_RENDER_LIMIT:]
154
+ note = f"Showing latest {len(visible)} of {total} comments.\n\n" if total > COMMENT_RENDER_LIMIT else ""
155
+ sections = []
156
+ for index, comment in enumerate(visible, start=1):
157
+ meta = f"Created: {comment.created}\n\n" if comment.created else ""
158
+ body = comment.body.strip() or "_No comment body available._"
159
+ sections.append(f"#### Comment {index}\n\n{meta}{body}")
160
+ return note + "\n\n".join(sections)
161
+
162
+
163
+ def _attachments_markdown(issue: IssueArtifact) -> str:
164
+ attachments = issue.details.attachments
165
+ if not attachments:
166
+ return "No attachments found.\n\nAttachment content is not downloaded by bugpilot."
167
+ lines = [
168
+ "Attachment content is not downloaded by bugpilot.",
169
+ "",
170
+ "| Filename | Type | Size | Created |",
171
+ "|---|---|---:|---|",
172
+ ]
173
+ for attachment in attachments:
174
+ lines.append(
175
+ f"| {_table_cell(attachment.filename)} | {_table_cell(attachment.kind)} "
176
+ f"| {_format_size(attachment.size)} | {_table_cell(attachment.created)} |"
177
+ )
178
+ return "\n".join(lines)
179
+
180
+
181
+ def _bullets(values: tuple[str, ...], empty: str, quote: bool = False) -> str:
182
+ if not values:
183
+ return empty
184
+ return "\n".join(f'- "{value}"' if quote else f"- {value}" for value in values)
185
+
186
+
187
+ def _issue_details_markdown(issue: IssueArtifact) -> str:
188
+ """The report, then what the parser extracted from it: one section, both sources.
189
+
190
+ The description is shown for a hand-written bug too; it used to reach the
191
+ context only through the Jira summary, so a manual bug's own words were
192
+ missing from the document written for the agent.
193
+ """
194
+ details = issue.details
195
+ steps = details.reproduction_steps
196
+ steps_text = "\n".join(f"{i}. {step}" for i, step in enumerate(steps, 1)) if steps else "Not found."
197
+ traces = issue.signals.stack_traces
198
+ traces_text = "\n\n".join(f"```\n{trace}\n```" for trace in traces) if traces else "None found."
199
+ comment_text = "\n".join(comment.body for comment in issue.comments).lower()
200
+ comment_signals = [term for term in COMMENT_SIGNAL_TERMS if term in comment_text]
201
+ latest_comment = max((c.created for c in issue.comments if c.created), default="")
202
+ attachment_kinds = sorted({attachment.kind for attachment in details.attachments})
203
+ missing = _bullets(details.missing_information, "- No missing information identified.")
204
+ jira = (
205
+ "### Jira Fields\n\n"
206
+ f"{_jira_fields_markdown(issue)}\n\n"
207
+ "### Comments\n\n"
208
+ f"{_comments_markdown(issue)}\n\n"
209
+ "### Attachments\n\n"
210
+ f"{_attachments_markdown(issue)}\n\n"
211
+ if issue.is_jira
212
+ else ""
213
+ )
214
+ reading = (
215
+ "\n\n### Reading Comments and Attachments\n\n"
216
+ "- Use Jira comments as additional context; comments may be newer than the original description.\n"
217
+ "- Review Attachment Signals and attachment metadata when present.\n"
218
+ "- Do not assume attachment content was read unless the content appears in repository files or generated artifacts.\n"
219
+ "- If a log or crash dump attachment exists but was not downloaded, note attachment review as a follow-up."
220
+ if issue.is_jira
221
+ else ""
222
+ )
223
+ return (
224
+ "### Description\n\n"
225
+ f"{issue.description or _NOT_SPECIFIED}\n\n"
226
+ f"{jira}"
227
+ "### Reproduction Steps\n\n"
228
+ f"{steps_text}\n\n"
229
+ "### Actual Result\n\n"
230
+ f"{details.actual_result.strip() or 'Not found.'}\n\n"
231
+ "### Expected Result\n\n"
232
+ f"{details.expected_result.strip() or 'Not found.'}\n\n"
233
+ "### Environment / Version\n\n"
234
+ f"{details.environment.strip() or 'Not found.'}\n\n"
235
+ "### Error Messages\n\n"
236
+ f"{_bullets(issue.signals.error_messages, 'None found.')}\n\n"
237
+ "### Stack Traces\n\n"
238
+ f"{traces_text}\n\n"
239
+ "### Log Signals\n\n"
240
+ f"{_bullets(issue.signals.log_signals, 'None found.')}\n\n"
241
+ "### Regression Signals\n\n"
242
+ f"{_bullets(details.regression_signals, 'None found.', quote=True)}\n\n"
243
+ "### Comment Signals\n\n"
244
+ f"- Number of comments: {len(issue.comments)}\n"
245
+ f"- Latest comment timestamp: {latest_comment or '_None_'}\n"
246
+ f"- Signals found: {', '.join(comment_signals) or '_None_'}\n\n"
247
+ "### Attachment Signals\n\n"
248
+ f"- Number of attachments: {len(details.attachments)}\n"
249
+ f"- Attachment kinds found: {', '.join(attachment_kinds) or '_None_'}\n\n"
250
+ "### Missing Information Checklist\n\n"
251
+ f"{missing}"
252
+ f"{reading}"
253
+ )
254
+
255
+
256
+ def _format_size(size: int) -> str:
257
+ if size >= 1024 * 1024:
258
+ return f"{size / (1024 * 1024):.1f} MB"
259
+ if size >= 1024:
260
+ return f"{round(size / 1024)} KB"
261
+ return f"{size} B"
262
+
263
+
264
+ def _table_cell(value: str) -> str:
265
+ return value.replace("|", "\\|")
266
+
267
+
268
+ #: How many lines of matched-line evidence the context carries; the rest is in
269
+ #: retrieval.json.
270
+ _SNIPPET_EXCERPT_LINES = 25
271
+
272
+
273
+ def _related_file_line(item: RelatedFile) -> str:
274
+ return (
275
+ f"- `{item.file}` confidence={item.confidence} score={item.score} "
276
+ f"matches={item.match_count} keywords={', '.join(item.matched_keywords)}"
277
+ )
278
+
279
+
280
+ def _relevant_files_markdown(retrieval: RetrievalArtifact | None) -> str:
281
+ """Every ranked file on one line each — all of them.
282
+
283
+ This is the one list that names every file the search kept, and
284
+ `--max-files` already bounds it at retrieval. A cap here would be a second,
285
+ silent one: it used to be ten, the panel's row limit and the default, so a
286
+ developer who asked for eleven files got a context naming ten (§37.12).
287
+ It also replaces a separate top-five list that repeated its first lines.
288
+
289
+ Search warnings are not repeated here: they are already listed under Search
290
+ Quality, as reasons.
291
+ """
292
+ if retrieval is None:
293
+ return "_Code search has not been generated yet._"
294
+ if not retrieval.related_files:
295
+ return "_No related files found._"
296
+ return "\n".join(_related_file_line(item) for item in retrieval.related_files)
297
+
298
+
299
+ def _search_quality_markdown(retrieval: RetrievalArtifact | None) -> str:
300
+ if retrieval is None:
301
+ return "_Search quality has not been generated yet._"
302
+ lines = [f"Confidence: {retrieval.confidence.title()}", "", "Reasons:"]
303
+ if retrieval.reasons:
304
+ lines.extend(f"- {reason}" for reason in retrieval.reasons)
305
+ else:
306
+ lines.append("- No search quality reasons available.")
307
+ return "\n".join(lines)
308
+
309
+
310
+ def _matched_lines_excerpt(retrieval: RetrievalArtifact | None) -> str:
311
+ """The first matched lines, file by file, in rank order."""
312
+ if retrieval is None:
313
+ return "_No matched snippets available._"
314
+ lines: list[str] = []
315
+ for item in retrieval.related_files:
316
+ if not item.snippets:
317
+ continue
318
+ lines.extend([f"#### {item.file}", ""])
319
+ lines.extend(f"- Line {snippet.line}: `{snippet.text}`" for snippet in item.snippets)
320
+ lines.append("")
321
+ # One line fewer than the budget: the old report's excerpt spent its first
322
+ # line on the blank under the section heading.
323
+ excerpt = "\n".join(lines[: _SNIPPET_EXCERPT_LINES - 1]).strip()
324
+ return excerpt or "_No matched snippets available._"
325
+
326
+
327
+ def _similar_fixes_markdown(similar_fixes: str | None) -> str:
328
+ if similar_fixes is None:
329
+ return "_Memory search has not been generated yet._"
330
+ return _section_excerpt(similar_fixes, "## Similar Historical Issues") or "No similar memory entries found."
331
+
332
+
333
+ def _git_history_markdown(git_history: GitHistoryOutcome | None) -> str:
334
+ if git_history is None:
335
+ return "_Git context has not been generated yet._"
336
+ return _demote(render_git_context(git_history))
337
+
338
+
339
+ def _section_excerpt(markdown: str, heading: str, max_lines: int = 12) -> str:
340
+ lines = markdown.splitlines()
341
+ try:
342
+ start = lines.index(heading) + 1
343
+ except ValueError:
344
+ return ""
345
+ collected = []
346
+ for line in lines[start:]:
347
+ if line.startswith("## ") and collected:
348
+ break
349
+ collected.append(line)
350
+ if len(collected) >= max_lines:
351
+ break
352
+ return "\n".join(collected).strip()
353
+
354
+
355
+ # Jira states that mean the issue is not open work. Deliberately a small,
356
+ # explicit set: an unfamiliar workflow falls through and says nothing, which is
357
+ # better than labelling a live issue as settled.
358
+ _SETTLED_STATUSES = {"closed", "done", "resolved", "cancelled", "canceled"}
359
+
360
+
361
+ def _caution_markdown(issue: IssueArtifact) -> str:
362
+ """Facts about the issue that change what the reader should do.
363
+
364
+ A real run prepared a complete fix package — branch name, fix workflow,
365
+ twenty-three files — for an issue that was Closed, resolved "Won't Do", and
366
+ typed Task rather than Bug. All three facts were *in* the package; none of
367
+ them was pointed at. The agent that read it was careful enough to notice; a
368
+ less careful one starts editing.
369
+
370
+ This states the facts and stops. bugpilot prepares context; whether to work
371
+ a resolved issue is the developer's call.
372
+ """
373
+ status = issue.details.status.strip()
374
+ resolution = issue.details.resolution.strip()
375
+ issue_type = issue.details.issue_type.strip()
376
+
377
+ cautions: list[str] = []
378
+ if status.lower() in _SETTLED_STATUSES:
379
+ detail = f"{status} (resolution: {resolution})" if resolution else status
380
+ cautions.append(
381
+ f"This issue is already {detail}. Preparing this package did not reopen it — "
382
+ "check with the reporter before working it."
383
+ )
384
+ if issue_type and issue_type.lower() not in {"bug", "defect"}:
385
+ cautions.append(
386
+ f"Jira types this as {issue_type}, not a Bug. Expect a request rather than a "
387
+ "failure: there may be no reproduction steps to follow and nothing broken to fix."
388
+ )
389
+ if not cautions:
390
+ return ""
391
+ return "## Caution\n\n" + "\n".join(f"- {line}" for line in cautions) + "\n\n"
392
+
393
+
394
+ # What a file list is worth, by how much the search believes in it. Counting
395
+ # files without regard to confidence is how a run whose own search reported
396
+ # "low, zero high-confidence files" still announced 90/100 — a headline that
397
+ # contradicted the section printed directly beneath it, and the first thing an
398
+ # agent reads.
399
+ _RELATED_FILE_POINTS = {"high": 25, "medium": 12, "low": 4}
400
+
401
+
402
+ def _quality_score(
403
+ issue: IssueArtifact,
404
+ keywords: dict[str, object],
405
+ retrieval: RetrievalArtifact | None,
406
+ memory_search: str,
407
+ git_context: GitHistoryOutcome | None,
408
+ ) -> tuple[int, list[str]]:
409
+ confidence = retrieval.confidence.lower() if retrieval is not None else "low"
410
+ file_count = len(retrieval.related_files) if retrieval is not None else 0
411
+ signals = [
412
+ f"Jira description found: {'yes' if issue.description else 'no'}",
413
+ f"High-value keywords found: {len(keywords.get('high_value_keywords', []))}",
414
+ # The confidence rides with the count, because the count alone reads as
415
+ # good news even when every file on the list is a generic-word match.
416
+ f"Related files found: {file_count} (search confidence: {confidence})",
417
+ f"Memory search results found: {'yes' if memory_search and 'No similar memory entries found.' not in memory_search else 'no'}",
418
+ f"Git context available: {'yes' if _git_available(git_context) else 'no'}",
419
+ ]
420
+ score = 20
421
+ if issue.description:
422
+ score += 20
423
+ if keywords.get("high_value_keywords"):
424
+ score += 20
425
+ if file_count:
426
+ score += _RELATED_FILE_POINTS.get(confidence, 4)
427
+ if memory_search and "No similar memory entries found." not in memory_search:
428
+ score += 10
429
+ if _git_available(git_context):
430
+ score += 5
431
+ return min(score, 100), signals
432
+
433
+
434
+ def _git_available(git_context: GitHistoryOutcome | None) -> bool:
435
+ """The step ran in a repository git could read — whatever it then found."""
436
+ return git_context is not None and git_context.record.status != "unavailable"
@@ -0,0 +1,47 @@
1
+ """AI agent availability checks and safe handoff guidance.
2
+
3
+ Split into collect/render halves like :mod:`doctor`: the collectors return data,
4
+ and rendering to stdout stays in the CLI layer. Core cannot print, because the
5
+ MCP server speaks JSON-RPC over stdout — one stray ``print`` there corrupts the
6
+ protocol frame and the client silently loses the connection.
7
+
8
+ See ``docs/adapter_design.md`` section 4.2, invariant 2.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from .config import load_config
16
+ from .git_ops import command_available
17
+ from .handoff import handoff_prompt
18
+
19
+
20
+ def collect_agent_status(repo_root: Path | None = None) -> dict[str, str | bool]:
21
+ """Report which coding agents are reachable, and in what mode bugpilot runs."""
22
+ config = load_config(repo_root or Path.cwd())
23
+ return {
24
+ "copilot_command": config.copilot_command,
25
+ "copilot_available": command_available(config.copilot_command),
26
+ "gh_available": command_available("gh"),
27
+ "claude_available": command_available(config.claude_command),
28
+ # What `bugpilot bug` does by default, and the one flag that changes it.
29
+ "automatic_invocation": "opt-in via `bugpilot bug <issue> --launch-agent claude|copilot`",
30
+ "default_mode": "prepare-only (existing artifacts kept; --fresh deletes them first)",
31
+ }
32
+
33
+
34
+ def agent_status_lines(repo_root: Path | None = None) -> list[str]:
35
+ """The ``bugpilot agent-check`` report, as lines for a caller to print."""
36
+ status = collect_agent_status(repo_root)
37
+ return ["bugpilot agent-check"] + [f"{key}: {value}" for key, value in status.items()]
38
+
39
+
40
+ def auto_invocation_guidance(issue_key: str) -> list[str]:
41
+ """What to tell a developer who must start their agent by hand."""
42
+ return [
43
+ "Agent automatic invocation is not enabled.",
44
+ "Open your AI agent from the target repo root and run:",
45
+ handoff_prompt(issue_key),
46
+ ]
47
+
@@ -0,0 +1,106 @@
1
+ """Shared agent delivery instruction text.
2
+
3
+ Delivery is two separable things, and conflating them cost a safety rule once
4
+ already. The *offer* — show a summary, ask to commit, run the git commands —
5
+ only makes sense when a fix exists. The *safety gate* — which branch may be
6
+ written to, what may never be staged — applies to any pass that could reach a
7
+ commit, including the implementation pass a developer starts by answering "yes"
8
+ to an investigation.
9
+
10
+ So they are separate blocks. An investigation-only task omits the offer and
11
+ keeps the gate; anything that can deliver gets both, in that order.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .branch_policy import DEFAULT_BRANCH_POLICY, delivery_branch_checks, delivery_branch_stop
17
+
18
+ DELIVERY_SAFETY_HEADING = "## BugPilot Delivery Safety"
19
+
20
+
21
+ def delivery_safety_block(
22
+ issue_key: str,
23
+ branch: str | None = None,
24
+ jira_comment: bool = True,
25
+ branch_policy: str = DEFAULT_BRANCH_POLICY,
26
+ ) -> str:
27
+ """Branch and staging rules for any commit, in any Fix Mode.
28
+
29
+ BugPilot-owned and never editable by a Fix Mode: these are the rules that
30
+ keep a token, a `.ai/` artifact or a commit on `main` out of the developer's
31
+ repository, and they do not become optional because a particular workflow
32
+ has nothing to commit yet. Which other branch is right is the developer's
33
+ branch policy (`branch_policy.py`); `main`/`master` never is, in any policy.
34
+ """
35
+ jira_rule = (
36
+ "Do not merge, create PRs, transition Jira, assign Jira, or change Jira fields. "
37
+ "The one status comment (posted before commit) is the only permitted Jira write.\n\n"
38
+ if jira_comment
39
+ else "Do not merge, create PRs, update Jira, transition Jira, assign Jira, or change Jira fields.\n\n"
40
+ )
41
+ return (
42
+ f"{DELIVERY_SAFETY_HEADING}\n\n"
43
+ "These rules apply to every commit or push made from this task, in every Fix Mode, "
44
+ "including a later implementation pass the developer starts from it.\n\n"
45
+ "Before staging anything:\n"
46
+ "- Verify the current branch is not `main` or `master`.\n"
47
+ "- Verify HEAD is not detached.\n"
48
+ f"{delivery_branch_checks(branch_policy, issue_key, branch)}"
49
+ "- Run `git add` only for intended source, test, or documentation files.\n"
50
+ "- Do not add `.ai/`.\n"
51
+ "- Do not add `.ai_memory/`.\n"
52
+ "- Do not add `issue.json`.\n"
53
+ "- Do not add `jira_field_report.md`.\n"
54
+ "- Do not add files containing `JIRA_TOKEN`, `password`, `api_key`, `secret`, `access_token`, `refresh_token`, or `key=...`.\n\n"
55
+ "Do not push main/master. Do not force push. Do not use `--force` or `--force-with-lease`. "
56
+ f"{jira_rule}"
57
+ "If on `main` or `master`, or HEAD is detached, do not commit and do not push. "
58
+ "Ask the developer whether to create or switch to the suggested branch.\n\n"
59
+ )
60
+
61
+
62
+ def assisted_delivery_block(
63
+ issue_key: str,
64
+ intro: str = "After completing code changes, focused tests, and the fix report",
65
+ branch_policy: str = DEFAULT_BRANCH_POLICY,
66
+ branch: str | None = None,
67
+ ) -> str:
68
+ """The commit/push offer, for a pass that actually produced a fix.
69
+
70
+ Deliberately does not restate the staging rules: they live in
71
+ `delivery_safety_block`, which is rendered for every Fix Mode, and a second
72
+ copy here is one more place for them to drift.
73
+ """
74
+ return (
75
+ "## Optional Assisted Delivery\n\n"
76
+ f"{intro}, show the developer a delivery summary with:\n"
77
+ "- `git status`\n"
78
+ "- changed files\n"
79
+ "- test result summary\n"
80
+ "- proposed commit message\n"
81
+ "- current branch\n"
82
+ "- target remote\n\n"
83
+ "Then ask exactly:\n\n"
84
+ "\"Do you want me to commit and push this branch to origin?\"\n\n"
85
+ "Only if the developer explicitly answers yes:\n"
86
+ f"- Apply every rule in {DELIVERY_SAFETY_HEADING.lstrip('# ')} above; stage nothing it excludes.\n"
87
+ "- Run `git commit` with the proposed message.\n"
88
+ "- Run `git push -u origin <current-branch>`.\n\n"
89
+ f"{delivery_branch_stop(branch_policy, issue_key, branch)}"
90
+ )
91
+
92
+
93
+ def delivery_instructions_block(
94
+ issue_key: str,
95
+ branch: str | None = None,
96
+ intro: str = "After completing code changes, focused tests, and the fix report",
97
+ jira_comment: bool = True,
98
+ branch_policy: str = DEFAULT_BRANCH_POLICY,
99
+ ) -> str:
100
+ """Safety gate followed by the assisted-delivery offer.
101
+
102
+ The composed form, for callers that always deliver a fix.
103
+ """
104
+ return delivery_safety_block(
105
+ issue_key, branch, jira_comment, branch_policy=branch_policy
106
+ ) + assisted_delivery_block(issue_key, intro, branch_policy=branch_policy, branch=branch)
@@ -0,0 +1,66 @@
1
+ """Environment diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .. import __version__
10
+ from .config import load_config, load_email_config, load_graph_config
11
+ from .git_ops import (
12
+ artifact_directories_ignored,
13
+ command_available,
14
+ current_branch,
15
+ inside_git_repo,
16
+ working_tree_status,
17
+ )
18
+
19
+
20
+ def collect_doctor_report(repo_root: Path) -> dict[str, str | bool | dict[str, bool] | None]:
21
+ config = load_config(repo_root)
22
+ email_config = load_email_config()
23
+ graph_config = load_graph_config()
24
+ in_git = command_available("git") and inside_git_repo(repo_root)
25
+ # Asked once; the summary and the per-directory answer are the same probe.
26
+ ignored = artifact_directories_ignored(repo_root)
27
+ return {
28
+ # First, and reported by every consumer of this dict, because "which
29
+ # bugpilot am I actually running?" is a real question: a machine can
30
+ # carry a pipx copy, an editable install and a frozen exe at once, and
31
+ # the extension shows this next to the path it resolved.
32
+ "version": __version__,
33
+ "python_version": platform.python_version(),
34
+ "python_ok": sys.version_info >= (3, 10),
35
+ "git_available": command_available("git"),
36
+ "current_directory": str(repo_root),
37
+ "inside_git_repo": in_git,
38
+ "current_branch": current_branch(repo_root) if in_git else None,
39
+ "working_tree_status": working_tree_status(repo_root) if in_git else None,
40
+ # False means one run will fill this repository's `git status` with
41
+ # artifacts, and someone will commit fetched Jira content. Reported
42
+ # rather than fixed: bugpilot does not edit a developer's .gitignore on
43
+ # its own. The per-directory answer is what lets the extension's quick
44
+ # fix add only the rule that is missing.
45
+ "ai_artifacts_ignored": None if ignored is None else all(ignored.values()),
46
+ "ai_artifacts_ignored_paths": ignored,
47
+ "rg_available": command_available("rg"),
48
+ "jira_base_url_present": bool(config.jira_base_url),
49
+ "jira_email_present": bool(config.jira_email),
50
+ "jira_token_present": bool(config.jira_token),
51
+ "copilot_available": command_available(config.copilot_command),
52
+ "claude_available": command_available(config.claude_command),
53
+ "email_configured": email_config.is_configured,
54
+ "email_graph_configured": graph_config.is_configured,
55
+ }
56
+
57
+
58
+ def doctor_report_lines(repo_root: Path) -> list[str]:
59
+ """The ``bugpilot doctor`` report, as lines for a caller to print.
60
+
61
+ Rendering stays out of core so the MCP server — which owns stdout for its
62
+ JSON-RPC frames — can consume :func:`collect_doctor_report` safely.
63
+ """
64
+ report = collect_doctor_report(repo_root)
65
+ return ["bugpilot doctor"] + [f"{key}: {value}" for key, value in report.items()]
66
+