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
bugpilot/core/context.py
ADDED
|
@@ -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"
|
bugpilot/core/copilot.py
ADDED
|
@@ -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)
|
bugpilot/core/doctor.py
ADDED
|
@@ -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
|
+
|