fixforward 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.
fixforward/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FixForward: incident-to-PR autopilot powered by GitHub Copilot CLI."""
2
+
3
+ __version__ = "0.1.0"
fixforward/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as python -m fixforward."""
2
+
3
+ from fixforward.cli import main
4
+
5
+ main()
@@ -0,0 +1,133 @@
1
+ """Failure classification engine using regex heuristics."""
2
+
3
+ import re
4
+ from enum import Enum
5
+ from dataclasses import dataclass
6
+ from typing import List
7
+
8
+ from fixforward.detector import TestFailure, Ecosystem
9
+
10
+
11
+ class FailureCategory(Enum):
12
+ SYNTAX_ERROR = "syntax_error"
13
+ DEPENDENCY = "dependency"
14
+ ENV_MISMATCH = "env_mismatch"
15
+ API_CHANGE = "api_change"
16
+ LINT = "lint"
17
+ FLAKY_TEST = "flaky_test"
18
+ ASSERTION = "assertion"
19
+ UNKNOWN = "unknown"
20
+
21
+
22
+ @dataclass
23
+ class ClassifiedFailure:
24
+ failure: TestFailure
25
+ category: FailureCategory
26
+ confidence: float
27
+ summary: str
28
+
29
+
30
+ # Classification rules in priority order (first match wins).
31
+ # Each rule: (category, confidence, [(pattern, summary_template)])
32
+ RULES = [
33
+ (FailureCategory.SYNTAX_ERROR, 0.95, [
34
+ (r"SyntaxError:\s*(.+)", "Syntax error: {0}"),
35
+ (r"IndentationError:\s*(.+)", "Indentation error: {0}"),
36
+ (r"Unexpected token\s*(.+)", "Unexpected token: {0}"),
37
+ (r"parse error", "Parse error"),
38
+ (r"expected\s+.+,\s+found\s+(.+)", "Expected/found mismatch: {0}"),
39
+ ]),
40
+ (FailureCategory.DEPENDENCY, 0.90, [
41
+ (r"ModuleNotFoundError:\s*No module named '(\S+)'", "Missing module: {0}"),
42
+ (r"ImportError:\s*(.+)", "Import error: {0}"),
43
+ (r"Cannot find module '(\S+)'", "Missing Node module: {0}"),
44
+ (r"No module named '?(\S+?)'?", "Missing module: {0}"),
45
+ (r"unresolved import `(\S+)`", "Unresolved import: {0}"),
46
+ (r"package `(\S+)`.+not found", "Missing Rust crate: {0}"),
47
+ (r"Could not find a version that satisfies", "Dependency version conflict"),
48
+ ]),
49
+ (FailureCategory.ENV_MISMATCH, 0.80, [
50
+ (r"version mismatch", "Version mismatch"),
51
+ (r"requires Python\s*([\d.]+)", "Requires Python {0}"),
52
+ (r"engine .+ is incompatible", "Engine incompatible"),
53
+ (r"ENOENT.+?'(\S+)'", "Command not found: {0}"),
54
+ (r"command not found:\s*(\S+)", "Command not found: {0}"),
55
+ (r"minimum supported rust version", "Rust version too old"),
56
+ ]),
57
+ (FailureCategory.API_CHANGE, 0.85, [
58
+ (r"AttributeError:\s*'?(\w+)'?\s+object has no attribute '(\w+)'",
59
+ "{0} has no attribute '{1}'"),
60
+ (r"TypeError:\s*(\w+)\(\) (?:got an unexpected|missing \d+ required|takes \d+)",
61
+ "Wrong arguments for {0}()"),
62
+ (r"missing \d+ required (?:positional )?argument", "Missing required argument"),
63
+ (r"has no member named `(\w+)`", "No member: {0}"),
64
+ (r"no method named `(\w+)`", "No method: {0}"),
65
+ (r"is not a function", "Not a function"),
66
+ (r"is not defined", "Not defined"),
67
+ ]),
68
+ (FailureCategory.LINT, 0.75, [
69
+ (r"flake8", "Flake8 lint error"),
70
+ (r"eslint", "ESLint error"),
71
+ (r"clippy", "Clippy warning"),
72
+ (r"warning\[(\w+)\]", "Compiler warning: {0}"),
73
+ (r"formatting.+differ", "Formatting difference"),
74
+ ]),
75
+ (FailureCategory.FLAKY_TEST, 0.60, [
76
+ (r"timeout|timed?\s*out", "Test timed out"),
77
+ (r"flaky", "Flaky test"),
78
+ (r"intermittent", "Intermittent failure"),
79
+ (r"connection refused", "Connection refused"),
80
+ (r"ECONNRESET", "Connection reset"),
81
+ (r"Resource temporarily unavailable", "Resource unavailable"),
82
+ ]),
83
+ (FailureCategory.ASSERTION, 0.85, [
84
+ (r"AssertionError:\s*assert\s+(.+)", "Assertion failed: {0}"),
85
+ (r"AssertionError:\s*(.+)", "Assertion: {0}"),
86
+ (r"assert\s+[\d.]+\s*==\s*[\d.]+", "Assertion: value mismatch"),
87
+ (r"assert\s+(.+?)\s*==\s*(.+)", "Assertion: {0} != {1}"),
88
+ (r"assert_eq!.+left:\s*`(.+?)`,\s*right:\s*`(.+?)`",
89
+ "assert_eq! left={0}, right={1}"),
90
+ (r"expect\(.+\)\.to(?:Equal|Be)\((.+?)\)", "Expected {0}"),
91
+ (r"Expected\s+(.+?)\s+to (?:equal|be)\s+(.+)", "Expected {1}, got {0}"),
92
+ (r"expected:\s*(.+?)\s+but was:\s*(.+)", "Expected {0}, got {1}"),
93
+ (r"!=\s", "Value mismatch"),
94
+ (r"AssertionError", "Assertion error"),
95
+ ]),
96
+ ]
97
+
98
+
99
+ def classify(
100
+ failures: List[TestFailure],
101
+ ecosystem: Ecosystem,
102
+ ) -> List[ClassifiedFailure]:
103
+ """Classify each test failure by category."""
104
+ return [_classify_one(f) for f in failures]
105
+
106
+
107
+ def _classify_one(failure: TestFailure) -> ClassifiedFailure:
108
+ """Classify a single failure."""
109
+ text = f"{failure.error_message}\n{failure.full_output}"
110
+
111
+ for category, base_confidence, patterns in RULES:
112
+ for pattern, summary_template in patterns:
113
+ m = re.search(pattern, text, re.IGNORECASE)
114
+ if m:
115
+ try:
116
+ summary = summary_template.format(*m.groups())
117
+ except (IndexError, KeyError):
118
+ summary = summary_template
119
+ return ClassifiedFailure(
120
+ failure=failure,
121
+ category=category,
122
+ confidence=base_confidence,
123
+ summary=summary,
124
+ )
125
+
126
+ # No match
127
+ error_preview = failure.error_message[:80] if failure.error_message else "Unknown error"
128
+ return ClassifiedFailure(
129
+ failure=failure,
130
+ category=FailureCategory.UNKNOWN,
131
+ confidence=0.3,
132
+ summary=error_preview,
133
+ )
fixforward/cli.py ADDED
@@ -0,0 +1,222 @@
1
+ """CLI entry point for FixForward."""
2
+
3
+ import argparse
4
+ import sys
5
+ import json
6
+
7
+ from fixforward import __version__
8
+ from fixforward.display import Display
9
+ from fixforward.detector import detect, run_tests, Ecosystem
10
+ from fixforward.classifier import classify
11
+ from fixforward.copilot import generate_patch, explain_failure, CopilotError
12
+ from fixforward.patcher import apply_patch, PatchError
13
+ from fixforward.verifier import verify
14
+ from fixforward.reporter import generate_report
15
+ from fixforward.state import rollback, RollbackError
16
+
17
+
18
+ def _cmd_run(args):
19
+ """Full autopilot: detect -> classify -> patch -> verify -> report."""
20
+ display = Display(animate=not args.no_animate)
21
+ display.show_banner()
22
+
23
+ # Step 1: Detect ecosystem
24
+ display.step(1, "Detecting project ecosystem...")
25
+ try:
26
+ ecosystem = detect(args.path)
27
+ except Exception as e:
28
+ display.error(f"Detection failed: {e}")
29
+ sys.exit(1)
30
+ display.ecosystem_found(ecosystem)
31
+
32
+ # Step 2: Run tests
33
+ display.step(2, "Running tests to capture failures...")
34
+ result = run_tests(args.path, ecosystem)
35
+
36
+ if result.passed:
37
+ display.all_tests_pass(result)
38
+ return
39
+
40
+ display.failures_found(result)
41
+
42
+ # Step 3: Classify failures
43
+ display.step(3, "Classifying failures...")
44
+ classifications = classify(result.failures, ecosystem)
45
+ display.show_classifications(classifications)
46
+
47
+ if args.dry_run:
48
+ display.dry_run_summary(classifications)
49
+ return
50
+
51
+ # Step 4: Generate patch via Copilot CLI
52
+ display.step(4, "Asking GitHub Copilot for a fix...")
53
+ try:
54
+ patch = generate_patch(
55
+ failures=classifications,
56
+ project_path=args.path,
57
+ ecosystem=ecosystem,
58
+ verbose=args.verbose,
59
+ )
60
+ except CopilotError as e:
61
+ display.error(f"Copilot failed: {e}")
62
+ sys.exit(1)
63
+
64
+ display.show_patch_preview(patch)
65
+
66
+ # Confirm
67
+ if not args.no_confirm:
68
+ if not display.confirm_apply():
69
+ display.aborted()
70
+ return
71
+
72
+ # Step 5: Apply patch
73
+ display.step(5, "Applying patch on a safe branch...")
74
+ try:
75
+ branch_info = apply_patch(patch, args.path)
76
+ except PatchError as e:
77
+ display.error(f"Patch failed: {e}")
78
+ sys.exit(1)
79
+ display.patch_applied(branch_info)
80
+
81
+ # Step 6: Verify
82
+ display.step(6, "Re-running tests to verify fix...")
83
+ verify_result = verify(args.path, ecosystem, result)
84
+ display.show_verification(verify_result)
85
+
86
+ # Generate report
87
+ pr_info = generate_report(
88
+ classifications=classifications,
89
+ patch=patch,
90
+ verify_result=verify_result,
91
+ ecosystem=ecosystem,
92
+ )
93
+ display.show_pr_report(pr_info)
94
+ display.done()
95
+
96
+
97
+ def _cmd_diagnose(args):
98
+ """Detect and classify failures without fixing."""
99
+ display = Display(animate=not getattr(args, "no_animate", False))
100
+ display.show_banner()
101
+
102
+ display.step(1, "Detecting project ecosystem...")
103
+ try:
104
+ ecosystem = detect(args.path)
105
+ except Exception as e:
106
+ display.error(f"Detection failed: {e}")
107
+ sys.exit(1)
108
+ display.ecosystem_found(ecosystem)
109
+
110
+ display.step(2, "Running tests to capture failures...")
111
+ result = run_tests(args.path, ecosystem)
112
+
113
+ if result.passed:
114
+ display.all_tests_pass(result)
115
+ return
116
+
117
+ display.failures_found(result)
118
+
119
+ display.step(3, "Classifying failures...")
120
+ classifications = classify(result.failures, ecosystem)
121
+
122
+ if getattr(args, "json_output", False):
123
+ data = [
124
+ {
125
+ "test": c.failure.test_name,
126
+ "file": c.failure.file_path,
127
+ "line": c.failure.line_number,
128
+ "category": c.category.value,
129
+ "confidence": c.confidence,
130
+ "summary": c.summary,
131
+ "error": c.failure.error_message,
132
+ }
133
+ for c in classifications
134
+ ]
135
+ print(json.dumps(data, indent=2))
136
+ else:
137
+ display.show_classifications(classifications)
138
+
139
+ # Ask Copilot to explain each failure
140
+ display.step(4, "Asking Copilot to explain failures...")
141
+ for c in classifications:
142
+ try:
143
+ explanation = explain_failure(c, args.path)
144
+ display.show_explanation(c, explanation)
145
+ except CopilotError:
146
+ display.show_explanation(c, "(Copilot CLI unavailable)")
147
+
148
+ display.diagnose_done()
149
+
150
+
151
+ def _cmd_rollback(args):
152
+ """Undo the last fixforward patch."""
153
+ display = Display(animate=False)
154
+ display.show_banner()
155
+
156
+ try:
157
+ info = rollback(args.path)
158
+ display.rollback_success(info)
159
+ except RollbackError as e:
160
+ display.error(f"Rollback failed: {e}")
161
+ sys.exit(1)
162
+
163
+
164
+ def main():
165
+ parser = argparse.ArgumentParser(
166
+ prog="fixforward",
167
+ description="Incident-to-PR autopilot powered by GitHub Copilot CLI.",
168
+ )
169
+ parser.add_argument(
170
+ "--version", "-v", action="version", version=f"fixforward {__version__}"
171
+ )
172
+
173
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
174
+
175
+ # run command
176
+ run_parser = subparsers.add_parser("run", help="Full autopilot: detect, fix, verify, report")
177
+ run_parser.add_argument("--path", "-p", default=".", help="Path to the project (default: .)")
178
+ run_parser.add_argument("--dry-run", "-n", action="store_true", help="Show plan without executing")
179
+ run_parser.add_argument("--no-confirm", action="store_true", help="Skip patch confirmation prompt")
180
+ run_parser.add_argument("--no-animate", action="store_true", help="Disable animations")
181
+ run_parser.add_argument("--verbose", action="store_true", help="Show raw Copilot output")
182
+
183
+ # diagnose command
184
+ diag_parser = subparsers.add_parser("diagnose", help="Detect and classify failures only")
185
+ diag_parser.add_argument("--path", "-p", default=".", help="Path to the project (default: .)")
186
+ diag_parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON")
187
+ diag_parser.add_argument("--no-animate", action="store_true", help="Disable animations")
188
+
189
+ # rollback command
190
+ rb_parser = subparsers.add_parser("rollback", help="Undo the last fixforward patch")
191
+ rb_parser.add_argument("--path", "-p", default=".", help="Path to the project (default: .)")
192
+
193
+ args = parser.parse_args()
194
+
195
+ if args.command is None:
196
+ # Default to run
197
+ args.command = "run"
198
+ args.path = "."
199
+ args.dry_run = False
200
+ args.no_confirm = False
201
+ args.no_animate = False
202
+ args.verbose = False
203
+
204
+ commands = {
205
+ "run": _cmd_run,
206
+ "diagnose": _cmd_diagnose,
207
+ "rollback": _cmd_rollback,
208
+ }
209
+
210
+ try:
211
+ commands[args.command](args)
212
+ except KeyboardInterrupt:
213
+ print("\nAborted.")
214
+ sys.exit(130)
215
+ except Exception as e:
216
+ from rich.console import Console
217
+ Console(stderr=True).print(f"[bold red]Error:[/] {e}")
218
+ sys.exit(1)
219
+
220
+
221
+ if __name__ == "__main__":
222
+ main()
fixforward/copilot.py ADDED
@@ -0,0 +1,287 @@
1
+ """GitHub Copilot CLI integration layer."""
2
+
3
+ import subprocess
4
+ import re
5
+ import difflib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ from fixforward.detector import Ecosystem
11
+ from fixforward.classifier import ClassifiedFailure
12
+
13
+
14
+ class CopilotError(Exception):
15
+ pass
16
+
17
+
18
+ @dataclass
19
+ class FileChange:
20
+ file_path: str
21
+ original_content: str
22
+ modified_content: str
23
+ diff: str
24
+
25
+
26
+ @dataclass
27
+ class PatchResult:
28
+ changes: List[FileChange]
29
+ explanation: str
30
+ raw_copilot_output: str
31
+
32
+
33
+ def generate_patch(
34
+ failures: List[ClassifiedFailure],
35
+ project_path: str,
36
+ ecosystem: Ecosystem,
37
+ verbose: bool = False,
38
+ ) -> PatchResult:
39
+ """Ask Copilot CLI to generate a fix for classified failures."""
40
+ prompt = _build_fix_prompt(failures, project_path, ecosystem)
41
+
42
+ raw_output = _run_copilot(prompt, project_path)
43
+
44
+ if verbose:
45
+ from rich.console import Console
46
+ Console(stderr=True).print(f"[dim]Copilot raw output:\n{raw_output}[/]")
47
+
48
+ # Parse response into file changes
49
+ changes = _parse_response(raw_output, project_path)
50
+ explanation = _extract_explanation(raw_output)
51
+
52
+ if not changes:
53
+ raise CopilotError(
54
+ "Copilot did not produce any file changes. "
55
+ "Try running again or fixing manually."
56
+ )
57
+
58
+ return PatchResult(
59
+ changes=changes,
60
+ explanation=explanation,
61
+ raw_copilot_output=raw_output,
62
+ )
63
+
64
+
65
+ def explain_failure(
66
+ classification: ClassifiedFailure,
67
+ project_path: str,
68
+ ) -> str:
69
+ """Ask Copilot CLI to explain a failure."""
70
+ prompt = (
71
+ f"Explain this test failure concisely:\n"
72
+ f"Test: {classification.failure.test_name}\n"
73
+ f"File: {classification.failure.file_path}\n"
74
+ f"Error: {classification.failure.error_message}\n"
75
+ f"Category: {classification.category.value}\n\n"
76
+ f"What is the likely root cause and how should it be fixed?"
77
+ )
78
+
79
+ try:
80
+ return _run_copilot(prompt, project_path)
81
+ except CopilotError:
82
+ return f"({classification.category.value}) {classification.summary}"
83
+
84
+
85
+ def _run_copilot(prompt: str, project_path: str, allow_write: bool = False) -> str:
86
+ """Execute gh copilot in non-interactive mode with -p flag."""
87
+ # Check if gh is available
88
+ try:
89
+ check = subprocess.run(
90
+ ["gh", "copilot", "--", "--version"],
91
+ capture_output=True,
92
+ text=True,
93
+ timeout=10,
94
+ )
95
+ except FileNotFoundError:
96
+ raise CopilotError(
97
+ "GitHub CLI (gh) is not installed. "
98
+ "Install it from: https://cli.github.com/"
99
+ )
100
+
101
+ # Build the command using non-interactive prompt mode
102
+ # gh copilot -- -p "prompt" --allow-all-tools --add-dir <path> --quiet
103
+ cmd = [
104
+ "gh", "copilot", "--",
105
+ "-p", prompt,
106
+ "--allow-all-tools",
107
+ "--add-dir", str(Path(project_path).resolve()),
108
+ "--silent",
109
+ ]
110
+
111
+ try:
112
+ result = subprocess.run(
113
+ cmd,
114
+ capture_output=True,
115
+ text=True,
116
+ cwd=str(Path(project_path).resolve()),
117
+ timeout=180,
118
+ )
119
+ except subprocess.TimeoutExpired:
120
+ raise CopilotError("Copilot CLI timed out after 180 seconds.")
121
+
122
+ output = result.stdout + result.stderr
123
+ if not output.strip():
124
+ raise CopilotError("Copilot returned empty response.")
125
+
126
+ return output.strip()
127
+
128
+
129
+ def _build_fix_prompt(
130
+ failures: List[ClassifiedFailure],
131
+ project_path: str,
132
+ ecosystem: Ecosystem,
133
+ ) -> str:
134
+ """Build a prompt for Copilot to generate a fix."""
135
+ failure_descriptions = []
136
+ source_context = []
137
+
138
+ for f in failures[:3]: # Limit to top 3 failures
139
+ desc = (
140
+ f"- [{f.category.value}] {f.failure.test_name}\n"
141
+ f" File: {f.failure.file_path}"
142
+ )
143
+ if f.failure.line_number:
144
+ desc += f":{f.failure.line_number}"
145
+ desc += f"\n Error: {f.failure.error_message}"
146
+ failure_descriptions.append(desc)
147
+
148
+ # Read source file for context
149
+ src_path = Path(project_path) / f.failure.file_path
150
+ if src_path.exists():
151
+ try:
152
+ content = src_path.read_text()
153
+ source_context.append(
154
+ f"--- {f.failure.file_path} ---\n{content}"
155
+ )
156
+ except Exception:
157
+ pass
158
+
159
+ failures_text = "\n".join(failure_descriptions)
160
+ sources_text = "\n\n".join(source_context) if source_context else "(no source files read)"
161
+
162
+ return (
163
+ f"I have a {ecosystem.value} project with failing tests. "
164
+ f"Generate a minimal fix.\n\n"
165
+ f"FAILURES:\n{failures_text}\n\n"
166
+ f"SOURCE FILES:\n{sources_text}\n\n"
167
+ f"Generate the smallest possible code change to fix these failures. "
168
+ f"Show the complete corrected file content for each file that needs changes. "
169
+ f"Format each fix as:\n"
170
+ f"FILE: <filepath>\n"
171
+ f"```\n<complete corrected file content>\n```\n\n"
172
+ f"Then explain what you changed and why."
173
+ )
174
+
175
+
176
+ def _parse_response(raw_output: str, project_path: str) -> List[FileChange]:
177
+ """Parse Copilot's response to extract file changes."""
178
+ changes = []
179
+
180
+ # Strategy 1: Look for FILE: <path> followed by code blocks
181
+ # Handle markdown bold: **FILE: app.py** or FILE: app.py
182
+ file_pattern = re.compile(
183
+ r"\*{0,2}FILE:\s*(.+?)\*{0,2}\s*\n\s*```\w*\n(.+?)```",
184
+ re.DOTALL,
185
+ )
186
+ for m in file_pattern.finditer(raw_output):
187
+ file_path = m.group(1).strip().strip("*")
188
+ new_content = m.group(2)
189
+
190
+ original_path = Path(project_path) / file_path
191
+ original_content = ""
192
+ if original_path.exists():
193
+ original_content = original_path.read_text()
194
+
195
+ if new_content.strip() != original_content.strip():
196
+ diff = _make_diff(file_path, original_content, new_content)
197
+ changes.append(FileChange(
198
+ file_path=file_path,
199
+ original_content=original_content,
200
+ modified_content=new_content,
201
+ diff=diff,
202
+ ))
203
+
204
+ # Strategy 2: Look for diff blocks
205
+ if not changes:
206
+ diff_pattern = re.compile(r"```diff\n(.+?)```", re.DOTALL)
207
+ for m in diff_pattern.finditer(raw_output):
208
+ diff_text = m.group(1)
209
+ # Try to extract filename from diff header
210
+ file_match = re.search(r"[+-]{3}\s+[ab]/(.+)", diff_text)
211
+ if file_match:
212
+ file_path = file_match.group(1).strip()
213
+ changes.append(FileChange(
214
+ file_path=file_path,
215
+ original_content="",
216
+ modified_content="",
217
+ diff=diff_text,
218
+ ))
219
+
220
+ # Strategy 3: Look for any code blocks with identifiable file content
221
+ if not changes:
222
+ code_pattern = re.compile(r"```(\w+)\n(.+?)```", re.DOTALL)
223
+ for m in code_pattern.finditer(raw_output):
224
+ lang = m.group(1)
225
+ content = m.group(2)
226
+
227
+ # Try to match against existing project files
228
+ ext_map = {"python": ".py", "javascript": ".js", "rust": ".rs",
229
+ "py": ".py", "js": ".js", "rs": ".rs"}
230
+ ext = ext_map.get(lang, "")
231
+ if ext:
232
+ project = Path(project_path)
233
+ for src_file in project.rglob(f"*{ext}"):
234
+ if src_file.is_file():
235
+ original = src_file.read_text()
236
+ # Check if this looks like a modified version
237
+ similarity = difflib.SequenceMatcher(
238
+ None, original, content
239
+ ).ratio()
240
+ if 0.5 < similarity < 1.0:
241
+ rel_path = str(src_file.relative_to(project))
242
+ diff = _make_diff(rel_path, original, content)
243
+ changes.append(FileChange(
244
+ file_path=rel_path,
245
+ original_content=original,
246
+ modified_content=content,
247
+ diff=diff,
248
+ ))
249
+ break
250
+
251
+ return changes
252
+
253
+
254
+ def _extract_explanation(raw_output: str) -> str:
255
+ """Extract the explanation section from Copilot's response."""
256
+ # Look for common markers
257
+ markers = [
258
+ r"(?:explanation|what changed|changes made|summary):?\s*\n(.+)",
259
+ r"(?:I changed|I fixed|The fix|This fixes|The issue).+",
260
+ ]
261
+ for pattern in markers:
262
+ m = re.search(pattern, raw_output, re.IGNORECASE | re.DOTALL)
263
+ if m:
264
+ text = m.group(0) if m.lastindex is None else m.group(1)
265
+ # Trim to reasonable length
266
+ lines = text.strip().splitlines()
267
+ return "\n".join(lines[:10])
268
+
269
+ # Fallback: last paragraph
270
+ paragraphs = raw_output.strip().split("\n\n")
271
+ if paragraphs:
272
+ return paragraphs[-1][:500]
273
+ return ""
274
+
275
+
276
+ def _make_diff(file_path: str, original: str, modified: str) -> str:
277
+ """Generate a unified diff string."""
278
+ original_lines = original.splitlines(keepends=True)
279
+ modified_lines = modified.splitlines(keepends=True)
280
+ diff = difflib.unified_diff(
281
+ original_lines,
282
+ modified_lines,
283
+ fromfile=f"a/{file_path}",
284
+ tofile=f"b/{file_path}",
285
+ lineterm="",
286
+ )
287
+ return "".join(diff)