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,412 @@
1
+ """AI Fix Mode definitions and registry.
2
+
3
+ Fix Modes describe *how* an AI coding agent should approach a prepared bug.
4
+ They are deliberately separate from bug evidence and from BugPilot-owned safety
5
+ rules. A mode may tune investigation, implementation and verification, but it
6
+ must never be able to weaken Git/Jira/destructive-action safeguards enforced by
7
+ BugPilot elsewhere.
8
+
9
+ One structured field, `execution_kind`, exists because prose alone cannot be
10
+ acted on: an investigation-only mode has to suppress the commit/push offer in
11
+ the generated task package, and a renderer cannot infer that from instruction
12
+ text. It is deliberately two values rather than the beginning of a workflow
13
+ DSL; anything more expressive belongs in the instruction sections, which are
14
+ prose for the agent and not logic for BugPilot.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ import re
21
+ from typing import Iterable, Literal
22
+
23
+ FixModeSource = Literal["builtin", "user", "project"]
24
+ ExecutionKind = Literal["fix", "investigate"]
25
+
26
+ DEFAULT_FIX_MODE_ID = "standard"
27
+
28
+ # Editable instruction text is interpolated into task.md as Markdown, and
29
+ # BugPilot owns that document's structure: the safety headings an agent looks for
30
+ # ("## Forbidden Actions", "## BugPilot Rule Precedence") must mean what BugPilot
31
+ # put there. A mode section carrying its own headings could forge a second copy
32
+ # of one, above the real one, and textual precedence cannot defend against a
33
+ # forged copy of the precedence rule. So sections are prose, not documents.
34
+ MAX_FIX_MODE_TEXT_LENGTH = 12_000
35
+ _MODE_ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
36
+ _MARKDOWN_HEADING_RE = re.compile(r"^#{1,6}\s", re.MULTILINE)
37
+ _SECTION_NAMES: tuple[str, ...] = (
38
+ "objective",
39
+ "investigation",
40
+ "implementation",
41
+ "verification",
42
+ "constraints",
43
+ "completion",
44
+ )
45
+ _VALID_SOURCES: frozenset[str] = frozenset({"builtin", "user", "project"})
46
+ _VALID_EXECUTION_KINDS: frozenset[str] = frozenset({"fix", "investigate"})
47
+
48
+
49
+ def is_valid_fix_mode_id(value: object) -> bool:
50
+ """Whether a value is a usable Fix Mode id.
51
+
52
+ Public because a custom mode's id becomes a file name, and the code that
53
+ builds that path has to ask the question before it builds anything — with
54
+ the model's own rule rather than a second copy of the pattern.
55
+ """
56
+ return isinstance(value, str) and bool(_MODE_ID_RE.fullmatch(value))
57
+
58
+
59
+ class FixModeError(ValueError):
60
+ """Base error for invalid Fix Mode definitions or lookup."""
61
+
62
+
63
+ class FixModeNotFoundError(FixModeError):
64
+ """Raised when a requested Fix Mode ID is not registered."""
65
+
66
+
67
+ def _require_text(value: object, field: str) -> str:
68
+ """A required text field, checked for type before content.
69
+
70
+ Custom modes will arrive from a file in a later phase, so `validate()` is
71
+ the boundary where a hand-edited definition becomes trusted. Reaching
72
+ `value.strip()` on a number or a list there would raise AttributeError or
73
+ TypeError — the wrong error, from the wrong layer, naming nothing the
74
+ developer can fix — so every field is type-checked first and every failure
75
+ leaves here as a FixModeError.
76
+ """
77
+ if not isinstance(value, str):
78
+ raise FixModeError(
79
+ f"Fix Mode field {field!r} must be a string, got {type(value).__name__}."
80
+ )
81
+ if not value.strip():
82
+ raise FixModeError(f"Fix Mode field {field!r} must not be empty.")
83
+ return value
84
+
85
+
86
+ def _require_document_safe_text(value: object, field: str) -> str:
87
+ """Instruction text that cannot restructure the document it is rendered into.
88
+
89
+ Only a heading at the start of a line is rejected, so `#` inside prose
90
+ ("issue #4102", "C#") is fine. An indented `#` is fine too, which keeps
91
+ comment lines in an example readable.
92
+ """
93
+ text = _require_text(value, field)
94
+ if _MARKDOWN_HEADING_RE.search(text):
95
+ raise FixModeError(
96
+ f"Fix Mode field {field!r} must not contain Markdown headings. BugPilot "
97
+ "owns the task document's structure, including its safety headings."
98
+ )
99
+ if len(text) > MAX_FIX_MODE_TEXT_LENGTH:
100
+ raise FixModeError(
101
+ f"Fix Mode field {field!r} exceeds the maximum length of "
102
+ f"{MAX_FIX_MODE_TEXT_LENGTH} characters."
103
+ )
104
+ return text
105
+
106
+
107
+ def _require_positive_int(value: object, field: str) -> int:
108
+ """A required integer >= 1. `True` is not 1 here, however Python feels."""
109
+ if isinstance(value, bool) or not isinstance(value, int):
110
+ raise FixModeError(
111
+ f"Fix Mode field {field!r} must be an integer, got {type(value).__name__}."
112
+ )
113
+ if value < 1:
114
+ raise FixModeError(f"Fix Mode field {field!r} must be >= 1.")
115
+ return value
116
+
117
+
118
+ def _require_choice(value: object, field: str, allowed: frozenset[str]) -> str:
119
+ if not isinstance(value, str):
120
+ raise FixModeError(
121
+ f"Fix Mode field {field!r} must be a string, got {type(value).__name__}."
122
+ )
123
+ if value not in allowed:
124
+ raise FixModeError(
125
+ f"Unsupported Fix Mode {field}: {value!r}. "
126
+ f"Expected one of: {', '.join(sorted(allowed))}."
127
+ )
128
+ return value
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class FixMode:
133
+ """Provider-neutral instructions that control an AI bug-fixing workflow."""
134
+
135
+ id: str
136
+ name: str
137
+ description: str
138
+ objective: str
139
+ investigation: str
140
+ implementation: str
141
+ verification: str
142
+ constraints: str
143
+ completion: str
144
+ source: FixModeSource = "builtin"
145
+ based_on: str | None = None
146
+ based_on_version: int | None = None
147
+ version: int = 1
148
+ execution_kind: ExecutionKind = "fix"
149
+
150
+ def validate(self) -> None:
151
+ """Validate a mode without depending on CLI, VS Code, MCP, or Jira."""
152
+ if not _MODE_ID_RE.fullmatch(_require_text(self.id, "id")):
153
+ raise FixModeError(
154
+ "Fix Mode id must start with a lowercase letter and contain only "
155
+ "lowercase letters, digits, or hyphens (max 64 characters)."
156
+ )
157
+ _require_text(self.name, "name")
158
+ _require_document_safe_text(self.description, "description")
159
+ _require_choice(self.source, "source", _VALID_SOURCES)
160
+ _require_choice(self.execution_kind, "execution_kind", _VALID_EXECUTION_KINDS)
161
+ _require_positive_int(self.version, "version")
162
+ if self.based_on is not None:
163
+ if not _MODE_ID_RE.fullmatch(_require_text(self.based_on, "based_on")):
164
+ raise FixModeError(f"Invalid based_on Fix Mode id: {self.based_on!r}.")
165
+ if self.based_on == self.id:
166
+ # Whether based_on names a mode that exists is the registry's
167
+ # question; it needs the whole mode set, which this does not have.
168
+ # Descent from itself needs no such context to be wrong.
169
+ raise FixModeError(
170
+ f"Fix Mode based_on must not equal its own id ({self.id!r})."
171
+ )
172
+ if self.based_on_version is not None:
173
+ _require_positive_int(self.based_on_version, "based_on_version")
174
+ if self.based_on is None:
175
+ # A version with no origin cannot be checked against anything,
176
+ # which is the one thing the field exists to allow.
177
+ raise FixModeError(
178
+ "Fix Mode based_on_version requires based_on to be set."
179
+ )
180
+ for section_name in _SECTION_NAMES:
181
+ _require_document_safe_text(getattr(self, section_name), section_name)
182
+
183
+ @property
184
+ def is_investigation(self) -> bool:
185
+ """True when no source change is expected in this mode's initial pass."""
186
+ return self.execution_kind == "investigate"
187
+
188
+ def instruction_sections(self) -> tuple[tuple[str, str], ...]:
189
+ """Return editable instruction sections in stable rendering order."""
190
+ return tuple((name, getattr(self, name)) for name in _SECTION_NAMES)
191
+
192
+
193
+ STANDARD_FIX = FixMode(
194
+ id="standard",
195
+ name="Standard Fix",
196
+ description="Default workflow for most bugs: analyze, fix minimally, verify, summarize.",
197
+ objective="Resolve the reported issue with the smallest correct change.",
198
+ investigation=(
199
+ "Read the supplied BugPilot context and relevant code before editing. "
200
+ "Identify the most likely root cause and verify it against the available evidence. "
201
+ "If important evidence is missing, state the assumption or request follow-up information."
202
+ ),
203
+ implementation=(
204
+ "Implement the smallest safe fix that addresses the root cause. Follow nearby project "
205
+ "patterns and avoid unrelated refactoring, formatting-only changes, or scope expansion."
206
+ ),
207
+ verification=(
208
+ "Run the most relevant focused tests or checks that are available. Review the final diff "
209
+ "for unintended changes. If a useful test cannot be run, explain why."
210
+ ),
211
+ constraints=(
212
+ "Do not make speculative changes. Avoid public API changes or new dependencies unless "
213
+ "they are required for the fix."
214
+ ),
215
+ completion=(
216
+ "Summarize the root cause, files changed, fix, verification performed, remaining risks, "
217
+ "and any follow-up information still needed."
218
+ ),
219
+ execution_kind="fix",
220
+ )
221
+
222
+ CONSERVATIVE_FIX = FixMode(
223
+ id="conservative",
224
+ name="Conservative Fix",
225
+ description="Minimal, low-risk changes for legacy or sensitive code.",
226
+ objective="Resolve the issue while preserving existing behavior outside the reported scope.",
227
+ investigation=(
228
+ "Understand the root cause before modifying code. Work from the evidence BugPilot "
229
+ "supplied, and review the Git/history evidence it includes when existing behavior may "
230
+ "be intentional. If historical intent matters but the supplied evidence is insufficient, "
231
+ "report the missing evidence instead of performing broad exploration. If the evidence is "
232
+ "not strong enough for a safe fix, stop and explain what additional information is required."
233
+ ),
234
+ implementation=(
235
+ "Prefer the smallest local change possible. Minimize changed files and changed lines, "
236
+ "preserve existing architecture, and follow the surrounding implementation style."
237
+ ),
238
+ verification=(
239
+ "Run focused regression checks around the changed behavior and review the diff carefully "
240
+ "for side effects. Document any verification that could not be completed."
241
+ ),
242
+ constraints=(
243
+ "Do not perform unrelated cleanup, modernization, broad refactoring, public API changes, "
244
+ "or dependency changes unless they are strictly required by the bug."
245
+ ),
246
+ completion=(
247
+ "Report the root cause, why the chosen fix is the minimum safe change, files changed, "
248
+ "verification, regression risks, and unresolved uncertainty."
249
+ ),
250
+ execution_kind="fix",
251
+ )
252
+
253
+ INVESTIGATE_FIRST_FIX = FixMode(
254
+ id="investigate-first",
255
+ name="Investigate First",
256
+ description="Diagnose and build an evidence-backed fix plan before source modification.",
257
+ objective="Establish a defensible root cause and fix plan before changing source code.",
258
+ investigation=(
259
+ "Do not modify source code in the initial pass. Analyze the supplied evidence, inspect the "
260
+ "likely execution path, identify candidate root causes, rank the hypotheses, and record what "
261
+ "evidence supports or weakens each one. Identify any additional evidence needed."
262
+ ),
263
+ implementation=(
264
+ "Produce a concrete proposed fix plan rather than editing source code. Name the likely files "
265
+ "or symbols to change and describe the smallest expected implementation."
266
+ ),
267
+ verification=(
268
+ "Define how the proposed fix should be verified, including focused automated tests, manual "
269
+ "reproduction, or diagnostic checks where applicable. Do not report verification as performed: "
270
+ "implementation has not started."
271
+ ),
272
+ constraints=(
273
+ "Do not guess a fix merely because a file matches keywords. Do not change source code until "
274
+ "the developer explicitly continues from the investigation result."
275
+ ),
276
+ completion=(
277
+ "Summarize the leading root-cause hypothesis, supporting evidence, competing hypotheses, "
278
+ "missing information, proposed fix plan, and proposed verification. State plainly that no "
279
+ "source change was applied, then stop and ask the developer whether to implement."
280
+ ),
281
+ execution_kind="investigate",
282
+ )
283
+
284
+ TEST_DRIVEN_FIX = FixMode(
285
+ id="test-driven",
286
+ name="Test-Driven Fix",
287
+ description="Reproduce with a focused test, fix the cause, then rerun verification.",
288
+ objective="Turn the reported behavior into a focused regression check and make that check pass.",
289
+ investigation=(
290
+ "Confirm the expected behavior from the supplied context and find the narrowest practical "
291
+ "test level that can reproduce the failure. Understand the root cause before broad changes."
292
+ ),
293
+ implementation=(
294
+ "Add or update a focused failing regression test when practical, then implement the smallest "
295
+ "production-code change that makes the test pass without weakening existing assertions."
296
+ ),
297
+ verification=(
298
+ "Run the new or updated regression test before and after the fix when practical, then run the "
299
+ "nearest relevant existing tests. Record commands and results accurately."
300
+ ),
301
+ constraints=(
302
+ "Do not rewrite large test areas just to enable the fix. Do not weaken tests to make a failure "
303
+ "disappear. If a reliable automated reproduction is not practical, explain that and use the "
304
+ "best focused verification available."
305
+ ),
306
+ completion=(
307
+ "Summarize the reproduced failure, root cause, test change, production fix, verification "
308
+ "results, and remaining regression risk."
309
+ ),
310
+ execution_kind="fix",
311
+ )
312
+
313
+ DEEP_ANALYSIS_FIX = FixMode(
314
+ id="deep-analysis",
315
+ name="Deep Analysis",
316
+ description="Deeper evidence review for complex crashes, regressions, or cross-module bugs.",
317
+ objective="Build a high-confidence explanation of the failure before selecting the safest fix.",
318
+ investigation=(
319
+ "Trace the relevant execution/data path across implicated modules and inspect the related "
320
+ "code the evidence points to. Prefer evidence over broad repository exploration: expand only "
321
+ "when the supplied evidence identifies a concrete dependency or missing link that must be "
322
+ "inspected. Review the Git/history evidence supplied by BugPilot when regression boundaries "
323
+ "or coupled changes may matter. Compare competing root-cause hypotheses against that "
324
+ "evidence. If historical intent matters but the supplied evidence is insufficient, report "
325
+ "the missing evidence instead of performing broad exploration."
326
+ ),
327
+ implementation=(
328
+ "After the analysis supports a root cause, implement the narrowest change that corrects it. "
329
+ "If multiple modules are involved, explain why each changed location is necessary."
330
+ ),
331
+ verification=(
332
+ "Run focused tests across the affected path and relevant boundary conditions. Review the diff "
333
+ "and check likely regression areas identified during analysis."
334
+ ),
335
+ constraints=(
336
+ "Depth of investigation does not authorize broad refactoring. Keep the implementation scoped "
337
+ "to the evidence-backed root cause and clearly label uncertainty."
338
+ ),
339
+ completion=(
340
+ "Summarize the execution path, root cause, evidence considered, changes made, verification, "
341
+ "regression risks, and any remaining uncertainty."
342
+ ),
343
+ execution_kind="fix",
344
+ )
345
+
346
+ BUILTIN_FIX_MODES: tuple[FixMode, ...] = (
347
+ STANDARD_FIX,
348
+ CONSERVATIVE_FIX,
349
+ INVESTIGATE_FIRST_FIX,
350
+ TEST_DRIVEN_FIX,
351
+ DEEP_ANALYSIS_FIX,
352
+ )
353
+
354
+
355
+ class FixModeRegistry:
356
+ """Validated lookup table shared by CLI, VS Code, MCP, and task generation."""
357
+
358
+ def __init__(self, modes: Iterable[FixMode] = BUILTIN_FIX_MODES) -> None:
359
+ self._modes: dict[str, FixMode] = {}
360
+ for mode in modes:
361
+ self.register(mode)
362
+ if DEFAULT_FIX_MODE_ID not in self._modes:
363
+ raise FixModeError(
364
+ f"Fix Mode registry must contain default mode {DEFAULT_FIX_MODE_ID!r}."
365
+ )
366
+
367
+ def register(self, mode: FixMode) -> None:
368
+ if not isinstance(mode, FixMode):
369
+ raise FixModeError(
370
+ f"Fix Mode registry accepts FixMode objects, got {type(mode).__name__}."
371
+ )
372
+ mode.validate()
373
+ if mode.id in self._modes:
374
+ raise FixModeError(f"Duplicate Fix Mode id: {mode.id!r}.")
375
+ self._modes[mode.id] = mode
376
+
377
+ def resolve(self, mode_id: str | None = None) -> FixMode:
378
+ """Resolve an exact ID; only ``None`` selects the deterministic default.
379
+
380
+ This is the only place an ID becomes a mode. Task generation is handed
381
+ the resolved object instead, so that user- and project-scoped modes can
382
+ be layered in here later without the renderer learning about config
383
+ files, repository roots, or scope precedence.
384
+ """
385
+ requested = DEFAULT_FIX_MODE_ID if mode_id is None else mode_id
386
+ if not isinstance(requested, str):
387
+ raise FixModeError(
388
+ f"Fix Mode id must be a string or None, got {type(mode_id).__name__}."
389
+ )
390
+ try:
391
+ return self._modes[requested]
392
+ except KeyError as exc:
393
+ available = ", ".join(self._modes)
394
+ raise FixModeNotFoundError(
395
+ f"Unknown Fix Mode {requested!r}. Available modes: {available}."
396
+ ) from exc
397
+
398
+ def list_modes(self, *, source: FixModeSource | None = None) -> tuple[FixMode, ...]:
399
+ modes = tuple(self._modes.values())
400
+ if source is None:
401
+ return modes
402
+ _require_choice(source, "source", _VALID_SOURCES)
403
+ return tuple(mode for mode in modes if mode.source == source)
404
+
405
+ @property
406
+ def default(self) -> FixMode:
407
+ return self.resolve()
408
+
409
+
410
+ def builtin_fix_mode_registry() -> FixModeRegistry:
411
+ """Return a fresh registry containing exactly BugPilot's packaged modes."""
412
+ return FixModeRegistry(BUILTIN_FIX_MODES)
@@ -0,0 +1,124 @@
1
+ """fix_report.md: the one post-agent workflow report (plan §37).
2
+
3
+ Five agent-written files used to carry the outcome — analysis, fix summary,
4
+ test result, diff summary, review notes — and BugPilot then derived four more
5
+ from them. One report remains, and the derivations (the Jira comment draft,
6
+ the notification email, the memory entry's Final Result, the validation
7
+ checklist, the retry prompt's previous-attempt summary) are rendered from it
8
+ in memory.
9
+
10
+ The report is **agent-owned**: the coding agent writes and updates it per
11
+ ``task.md``'s Required Output contract, and BugPilot never rewrites it. The
12
+ one BugPilot writer is the ``manual-result`` template, for a developer who
13
+ fixed by hand, and it refuses to overwrite an existing report unless asked.
14
+
15
+ The name means "post-agent workflow report", not "confirmed fix". An
16
+ investigation-only mode fills the same sections with investigation state, and
17
+ ``## Summary`` says honestly what happened: fixed, investigation only, no-op,
18
+ or an attempt that did not finish.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from .artifacts import FIX_REPORT_ARTIFACT
27
+ from .config import issue_dir
28
+
29
+ # The section contract task.md asks the agent for, in this order.
30
+ FIX_REPORT_SECTIONS: tuple[str, ...] = (
31
+ "## Summary",
32
+ "## Analysis",
33
+ "## Changes",
34
+ "## Tests",
35
+ "## Review Notes",
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class FixReport:
41
+ """The report as written, plus its sections. Empty string for a missing one."""
42
+
43
+ text: str
44
+ summary: str
45
+ analysis: str
46
+ changes: str
47
+ tests: str
48
+ review_notes: str
49
+
50
+ @property
51
+ def missing_sections(self) -> tuple[str, ...]:
52
+ by_heading = {
53
+ "## Summary": self.summary,
54
+ "## Analysis": self.analysis,
55
+ "## Changes": self.changes,
56
+ "## Tests": self.tests,
57
+ "## Review Notes": self.review_notes,
58
+ }
59
+ return tuple(heading for heading in FIX_REPORT_SECTIONS if not by_heading[heading])
60
+
61
+
62
+ def fix_report_path(repo_root: Path, work_item_id: str) -> Path:
63
+ return issue_dir(repo_root, work_item_id) / FIX_REPORT_ARTIFACT
64
+
65
+
66
+ def read_fix_report(repo_root: Path, work_item_id: str) -> FixReport | None:
67
+ """The agent's report, or ``None`` when nothing has written one yet.
68
+
69
+ Markdown a model wrote, read tolerantly: a missing heading is an empty
70
+ section, never an error — the readers all render "not available" for it.
71
+ """
72
+ path = fix_report_path(repo_root, work_item_id)
73
+ if not path.exists():
74
+ return None
75
+ text = path.read_text(encoding="utf-8", errors="replace")
76
+ return FixReport(
77
+ text=text,
78
+ summary=section_of(text, "## Summary"),
79
+ analysis=section_of(text, "## Analysis"),
80
+ changes=section_of(text, "## Changes"),
81
+ tests=section_of(text, "## Tests"),
82
+ review_notes=section_of(text, "## Review Notes"),
83
+ )
84
+
85
+
86
+ def section_of(markdown: str, heading: str) -> str:
87
+ """The body under a ``##`` heading, up to the next one. ``###`` stays inside.
88
+
89
+ Matched with trailing whitespace and case forgiven: the report is written by
90
+ a model, and ``## summary `` failing to parse would read as a missing
91
+ section everywhere downstream — safe, but a sharp edge for no gain.
92
+ """
93
+ wanted = heading.strip().lower()
94
+ lines = markdown.splitlines()
95
+ start = next(
96
+ (index + 1 for index, line in enumerate(lines) if line.strip().lower() == wanted),
97
+ None,
98
+ )
99
+ if start is None:
100
+ return ""
101
+ collected: list[str] = []
102
+ for line in lines[start:]:
103
+ if line.startswith("## "):
104
+ break
105
+ collected.append(line)
106
+ return "\n".join(collected).strip()
107
+
108
+
109
+ def manual_fix_report_template(work_item_id: str) -> str:
110
+ """The report skeleton for a developer who fixed the bug by hand."""
111
+ return (
112
+ f"# Fix Report: {work_item_id}\n\n"
113
+ "## Summary\n\n"
114
+ "Developer manual fix. TODO: one line on what was done and the outcome.\n\n"
115
+ "## Analysis\n\n"
116
+ "TODO: the root cause and the evidence for it.\n\n"
117
+ "## Changes\n\n"
118
+ "TODO: what changed, the files touched, and a short diff summary.\n\n"
119
+ "## Tests\n\n"
120
+ "TODO: the commands run and their outcomes — or state that tests were "
121
+ "not run, and why.\n\n"
122
+ "## Review Notes\n\n"
123
+ "TODO: risks, open questions, and the recommended next step.\n"
124
+ )