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 +3 -0
- fixforward/__main__.py +5 -0
- fixforward/classifier.py +133 -0
- fixforward/cli.py +222 -0
- fixforward/copilot.py +287 -0
- fixforward/detector.py +146 -0
- fixforward/display.py +359 -0
- fixforward/parsers/__init__.py +5 -0
- fixforward/parsers/cargo_parser.py +123 -0
- fixforward/parsers/npm_parser.py +154 -0
- fixforward/parsers/pytest_parser.py +158 -0
- fixforward/patcher.py +130 -0
- fixforward/reporter.py +103 -0
- fixforward/state.py +103 -0
- fixforward/verifier.py +93 -0
- fixforward-0.1.0.dist-info/METADATA +264 -0
- fixforward-0.1.0.dist-info/RECORD +21 -0
- fixforward-0.1.0.dist-info/WHEEL +5 -0
- fixforward-0.1.0.dist-info/entry_points.txt +2 -0
- fixforward-0.1.0.dist-info/licenses/LICENSE +21 -0
- fixforward-0.1.0.dist-info/top_level.txt +1 -0
fixforward/__init__.py
ADDED
fixforward/__main__.py
ADDED
fixforward/classifier.py
ADDED
|
@@ -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)
|