fixfleet 0.3.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.
- bugfixer/__init__.py +6 -0
- bugfixer/backends/__init__.py +1 -0
- bugfixer/backends/_subprocess.py +82 -0
- bugfixer/backends/api/__init__.py +1 -0
- bugfixer/backends/api/openai_compat.py +183 -0
- bugfixer/backends/base.py +52 -0
- bugfixer/backends/cli/__init__.py +1 -0
- bugfixer/backends/cli/aider.py +25 -0
- bugfixer/backends/cli/claude.py +22 -0
- bugfixer/backends/cli/codex.py +20 -0
- bugfixer/backends/cli/cursor.py +19 -0
- bugfixer/backends/cli/gemini.py +20 -0
- bugfixer/backends/cli/qwen.py +19 -0
- bugfixer/backends/registry.py +87 -0
- bugfixer/budget.py +119 -0
- bugfixer/cli.py +649 -0
- bugfixer/confidence.py +232 -0
- bugfixer/config.py +59 -0
- bugfixer/gitlab.py +158 -0
- bugfixer/locator.py +273 -0
- bugfixer/parser.py +181 -0
- bugfixer/prompt.py +155 -0
- bugfixer/state.py +71 -0
- bugfixer/ui.py +90 -0
- fixfleet-0.3.0.dist-info/METADATA +349 -0
- fixfleet-0.3.0.dist-info/RECORD +30 -0
- fixfleet-0.3.0.dist-info/WHEEL +5 -0
- fixfleet-0.3.0.dist-info/entry_points.txt +3 -0
- fixfleet-0.3.0.dist-info/licenses/LICENSE +21 -0
- fixfleet-0.3.0.dist-info/top_level.txt +1 -0
bugfixer/cli.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""CLI entry point — orchestrates the bug-fixing flow."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import budget, config, state, ui
|
|
9
|
+
from .backends.base import Backend, RunResult
|
|
10
|
+
from .backends.registry import (
|
|
11
|
+
API_PRESETS,
|
|
12
|
+
build_api_backend,
|
|
13
|
+
detect_available_clis,
|
|
14
|
+
detect_unavailable_clis,
|
|
15
|
+
list_cli_backends,
|
|
16
|
+
)
|
|
17
|
+
from .confidence import evaluate as evaluate_confidence
|
|
18
|
+
from .gitlab import fetch_bug_issues
|
|
19
|
+
from .locator import locate
|
|
20
|
+
from .parser import parse_issue
|
|
21
|
+
from .prompt import build_prompt
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Path sanitization ──────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def _sanitize_path(raw: str) -> str:
|
|
27
|
+
"""Clean common shell-pasted path mistakes:
|
|
28
|
+
- Strip leading 'cd ' (user copied a cd command)
|
|
29
|
+
- Strip surrounding quotes (single, double, smart)
|
|
30
|
+
- Unescape backslash-space (e.g. 'App\\ Aspect' → 'App Aspect')
|
|
31
|
+
- Trim whitespace
|
|
32
|
+
"""
|
|
33
|
+
s = raw.strip()
|
|
34
|
+
# Strip leading 'cd ' command
|
|
35
|
+
if s.lower().startswith("cd "):
|
|
36
|
+
s = s[3:].strip()
|
|
37
|
+
# Strip wrapping quotes
|
|
38
|
+
for q in ('"', "'", "`", "“", "”", "‘", "’"):
|
|
39
|
+
if len(s) >= 2 and s.startswith(q) and s.endswith(q):
|
|
40
|
+
s = s[1:-1].strip()
|
|
41
|
+
break
|
|
42
|
+
# Unescape backslash-escaped spaces and parens (zsh/bash drag-and-drop)
|
|
43
|
+
s = s.replace("\\ ", " ").replace("\\(", "(").replace("\\)", ")")
|
|
44
|
+
s = s.replace("\\&", "&").replace("\\'", "'")
|
|
45
|
+
return s.strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Backend selection ──────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def _step_choose_backend(cfg: dict) -> Backend:
|
|
51
|
+
ui.print_section("Step 0 — Choose Backend")
|
|
52
|
+
|
|
53
|
+
available = detect_available_clis()
|
|
54
|
+
missing = detect_unavailable_clis()
|
|
55
|
+
|
|
56
|
+
options: list = [] # list of (label, callable returning Backend)
|
|
57
|
+
|
|
58
|
+
if available:
|
|
59
|
+
ui.print_info(f"{ui.GREEN}Detected installed CLIs:{ui.RESET}")
|
|
60
|
+
for b in available:
|
|
61
|
+
idx = len(options) + 1
|
|
62
|
+
options.append(("cli", b))
|
|
63
|
+
ui.print_info(f" [{idx}] {ui.BOLD}{b.display_name}{ui.RESET} {ui.DIM}(`{b.requires_binary}`){ui.RESET}")
|
|
64
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
65
|
+
|
|
66
|
+
if missing:
|
|
67
|
+
ui.print_info(f"{ui.DIM}Not installed:{ui.RESET}")
|
|
68
|
+
for b in missing:
|
|
69
|
+
ui.print_info(f" {ui.DIM}- {b.display_name} ({b.requires_binary}){ui.RESET}")
|
|
70
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
71
|
+
|
|
72
|
+
api_idx = len(options) + 1
|
|
73
|
+
options.append(("api", None))
|
|
74
|
+
ui.print_info(f"{ui.GREEN}API option:{ui.RESET}")
|
|
75
|
+
ui.print_info(f" [{api_idx}] {ui.BOLD}OpenAI-Compatible API{ui.RESET} "
|
|
76
|
+
f"{ui.DIM}(Groq / Gemini / OpenRouter / Ollama / ...){ui.RESET}")
|
|
77
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
78
|
+
|
|
79
|
+
# Honor env var override
|
|
80
|
+
forced = os.environ.get("BUGFIXER_BACKEND", "").strip().lower()
|
|
81
|
+
if forced:
|
|
82
|
+
for b in list_cli_backends():
|
|
83
|
+
if b.name == forced and b.available():
|
|
84
|
+
ui.print_success(f"Using {b.display_name} (BUGFIXER_BACKEND={forced})")
|
|
85
|
+
ui.print_end()
|
|
86
|
+
return b
|
|
87
|
+
if forced == "openai_compat" or forced == "api":
|
|
88
|
+
backend = _build_api_from_config(cfg)
|
|
89
|
+
if backend:
|
|
90
|
+
ui.print_success(f"Using API backend (BUGFIXER_BACKEND={forced})")
|
|
91
|
+
ui.print_end()
|
|
92
|
+
return backend
|
|
93
|
+
|
|
94
|
+
# Honor saved default
|
|
95
|
+
saved = cfg.get("default_backend") or ""
|
|
96
|
+
default_hint = ""
|
|
97
|
+
default_index = None
|
|
98
|
+
if saved:
|
|
99
|
+
for i, (kind, b) in enumerate(options, 1):
|
|
100
|
+
if kind == "cli" and b and b.name == saved:
|
|
101
|
+
default_index = i
|
|
102
|
+
default_hint = f" {ui.DIM}(saved default: {saved}, press Enter){ui.RESET}"
|
|
103
|
+
break
|
|
104
|
+
if saved == "openai_compat" and default_index is None:
|
|
105
|
+
default_index = api_idx
|
|
106
|
+
default_hint = f" {ui.DIM}(saved default: API, press Enter){ui.RESET}"
|
|
107
|
+
|
|
108
|
+
ui.print_info(f"Pick a backend{default_hint}")
|
|
109
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
110
|
+
raw = ui.ask_input("Choice")
|
|
111
|
+
|
|
112
|
+
if not raw and default_index is not None:
|
|
113
|
+
choice_idx = default_index
|
|
114
|
+
else:
|
|
115
|
+
try:
|
|
116
|
+
choice_idx = int(raw)
|
|
117
|
+
except ValueError:
|
|
118
|
+
ui.print_error("Invalid choice.")
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
if not (1 <= choice_idx <= len(options)):
|
|
122
|
+
ui.print_error("Choice out of range.")
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
kind, backend = options[choice_idx - 1]
|
|
126
|
+
if kind == "cli":
|
|
127
|
+
cfg["default_backend"] = backend.name
|
|
128
|
+
config.save(cfg)
|
|
129
|
+
ui.print_success(f"Selected: {backend.display_name}")
|
|
130
|
+
ver = backend.version()
|
|
131
|
+
if ver:
|
|
132
|
+
ui.print_info(f"Version: {ui.DIM}{ver}{ui.RESET}")
|
|
133
|
+
ui.print_end()
|
|
134
|
+
return backend
|
|
135
|
+
|
|
136
|
+
# API path
|
|
137
|
+
backend = _step_configure_api(cfg)
|
|
138
|
+
cfg["default_backend"] = "openai_compat"
|
|
139
|
+
config.save(cfg)
|
|
140
|
+
ui.print_end()
|
|
141
|
+
return backend
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_api_from_config(cfg: dict) -> Backend:
|
|
145
|
+
api_cfg = cfg.get("api") or {}
|
|
146
|
+
if api_cfg.get("base_url") and api_cfg.get("model"):
|
|
147
|
+
return build_api_backend(
|
|
148
|
+
base_url=api_cfg["base_url"],
|
|
149
|
+
api_key=api_cfg.get("api_key", ""),
|
|
150
|
+
model=api_cfg["model"],
|
|
151
|
+
)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _step_configure_api(cfg: dict) -> Backend:
|
|
156
|
+
ui.print_info(f"\n {ui.BOLD}API Configuration{ui.RESET}")
|
|
157
|
+
|
|
158
|
+
saved = cfg.get("api") or {}
|
|
159
|
+
if saved.get("base_url") and saved.get("model"):
|
|
160
|
+
ui.print_info(f"Saved: {ui.DIM}{saved.get('preset', 'custom')} | "
|
|
161
|
+
f"{saved['base_url']} | {saved['model']}{ui.RESET}")
|
|
162
|
+
ui.print_info(f"Press {ui.BOLD}Enter{ui.RESET} to reuse, or type {ui.BOLD}'new'{ui.RESET} to reconfigure")
|
|
163
|
+
ans = ui.ask_input("Action").lower()
|
|
164
|
+
if ans != "new":
|
|
165
|
+
return build_api_backend(
|
|
166
|
+
base_url=saved["base_url"],
|
|
167
|
+
api_key=saved.get("api_key", ""),
|
|
168
|
+
model=saved["model"],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
ui.print_info("Pick a preset:")
|
|
172
|
+
preset_keys = list(API_PRESETS.keys())
|
|
173
|
+
for i, k in enumerate(preset_keys, 1):
|
|
174
|
+
p = API_PRESETS[k]
|
|
175
|
+
ui.print_info(f" [{i}] {ui.BOLD}{p['label']}{ui.RESET}")
|
|
176
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
177
|
+
|
|
178
|
+
raw = ui.ask_input("Preset")
|
|
179
|
+
try:
|
|
180
|
+
idx = int(raw) - 1
|
|
181
|
+
except ValueError:
|
|
182
|
+
ui.print_error("Invalid preset choice.")
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
if not (0 <= idx < len(preset_keys)):
|
|
185
|
+
ui.print_error("Out of range.")
|
|
186
|
+
sys.exit(1)
|
|
187
|
+
|
|
188
|
+
pkey = preset_keys[idx]
|
|
189
|
+
preset = API_PRESETS[pkey]
|
|
190
|
+
|
|
191
|
+
base_url = preset["base_url"]
|
|
192
|
+
if pkey == "custom" or not base_url:
|
|
193
|
+
base_url = ui.ask_input("Base URL (e.g. https://host/v1)")
|
|
194
|
+
|
|
195
|
+
if preset["key_url"]:
|
|
196
|
+
ui.print_info(f"Get a free API key: {ui.DIM}{preset['key_url']}{ui.RESET}")
|
|
197
|
+
|
|
198
|
+
needs_key = pkey not in ("ollama", "lmstudio")
|
|
199
|
+
api_key = ui.ask_secret("API key (leave blank for none)") if needs_key else ""
|
|
200
|
+
|
|
201
|
+
default_model = preset["default_model"]
|
|
202
|
+
model_in = ui.ask_input(f"Model [{default_model}]" if default_model else "Model")
|
|
203
|
+
model = model_in or default_model
|
|
204
|
+
if not model:
|
|
205
|
+
ui.print_error("Model is required.")
|
|
206
|
+
sys.exit(1)
|
|
207
|
+
|
|
208
|
+
cfg["api"] = {
|
|
209
|
+
"preset": pkey,
|
|
210
|
+
"base_url": base_url,
|
|
211
|
+
"api_key": api_key,
|
|
212
|
+
"model": model,
|
|
213
|
+
}
|
|
214
|
+
config.save(cfg)
|
|
215
|
+
ui.print_success(f"Configured: {model} @ {base_url}")
|
|
216
|
+
return build_api_backend(base_url=base_url, api_key=api_key, model=model)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── Steps 1-5 (token, project, dir, date, fetch) ───────────────
|
|
220
|
+
|
|
221
|
+
def _step_token() -> str:
|
|
222
|
+
ui.print_section("Step 1 — GitLab Token")
|
|
223
|
+
ui.print_info("Paste your GitLab Personal Access Token")
|
|
224
|
+
ui.print_info(f"{ui.DIM}(starts with glpat-... | typing is hidden){ui.RESET}")
|
|
225
|
+
ui.print_info(f"{ui.DIM}Required scope: 'api' or 'read_api'{ui.RESET}")
|
|
226
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
227
|
+
token = ui.ask_secret("Token")
|
|
228
|
+
if not token:
|
|
229
|
+
ui.print_error("Token cannot be empty!")
|
|
230
|
+
sys.exit(1)
|
|
231
|
+
ui.print_success("Token received")
|
|
232
|
+
ui.print_end()
|
|
233
|
+
return token
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _step_project_id(cfg: dict) -> tuple:
|
|
237
|
+
"""Return (host, project_path)."""
|
|
238
|
+
from .gitlab import parse_project_input
|
|
239
|
+
|
|
240
|
+
ui.print_section("Step 2 — GitLab Project")
|
|
241
|
+
ui.print_info(f"{ui.BOLD}Just paste the GitLab project URL — we'll handle the rest.{ui.RESET}")
|
|
242
|
+
ui.print_info("")
|
|
243
|
+
ui.print_info(f"{ui.WHITE}Any of these work:{ui.RESET}")
|
|
244
|
+
ui.print_info(f" {ui.GREEN}https://gitlab.com/group/project{ui.RESET}")
|
|
245
|
+
ui.print_info(f" {ui.GREEN}https://gitlab.com/group/project.git{ui.RESET}")
|
|
246
|
+
ui.print_info(f" {ui.GREEN}https://gitlab.com/group/subgroup/project/-/issues{ui.RESET}")
|
|
247
|
+
ui.print_info(f" {ui.GREEN}git@gitlab.com:group/project.git{ui.RESET}")
|
|
248
|
+
ui.print_info(f" {ui.GREEN}group/project{ui.RESET} {ui.DIM}(short form){ui.RESET}")
|
|
249
|
+
ui.print_info(f" {ui.GREEN}12345{ui.RESET} {ui.DIM}(numeric ID){ui.RESET}")
|
|
250
|
+
ui.print_info("")
|
|
251
|
+
ui.print_info(f"{ui.DIM}Self-hosted GitLab works too — host is auto-detected from URL.{ui.RESET}")
|
|
252
|
+
|
|
253
|
+
saved_id = cfg.get("default_project_id") or ""
|
|
254
|
+
saved_host = cfg.get("default_project_host") or ""
|
|
255
|
+
if saved_id:
|
|
256
|
+
ui.print_info("")
|
|
257
|
+
host_disp = saved_host or "gitlab.com"
|
|
258
|
+
ui.print_info(f"Saved default: {ui.GREEN}{saved_id}{ui.RESET} on {ui.DIM}{host_disp}{ui.RESET} {ui.DIM}(press Enter to reuse){ui.RESET}")
|
|
259
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
260
|
+
|
|
261
|
+
while True:
|
|
262
|
+
raw = ui.ask_input("Project URL or ID")
|
|
263
|
+
if not raw and saved_id:
|
|
264
|
+
host = saved_host or "gitlab.com"
|
|
265
|
+
project_id = saved_id
|
|
266
|
+
break
|
|
267
|
+
if not raw:
|
|
268
|
+
ui.print_error("Cannot be empty!")
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
host, project_id = parse_project_input(raw)
|
|
273
|
+
except ValueError as e:
|
|
274
|
+
ui.print_error(f"Could not parse that input: {e}")
|
|
275
|
+
ui.print_info("Try the full URL from your browser address bar.")
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
# Sanity check — looks like a folder path?
|
|
279
|
+
if raw.startswith(("/", "~")):
|
|
280
|
+
ui.print_error("That looks like a folder path, not a GitLab project.")
|
|
281
|
+
ui.print_info("Step 2 wants the GitLab URL. Step 3 is for the local folder.")
|
|
282
|
+
continue
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
cfg["default_project_id"] = project_id
|
|
286
|
+
cfg["default_project_host"] = host
|
|
287
|
+
config.save(cfg)
|
|
288
|
+
ui.print_success(f"Project: {ui.GREEN}{project_id}{ui.RESET}")
|
|
289
|
+
if host != "gitlab.com":
|
|
290
|
+
ui.print_info(f"Host: {ui.DIM}{host}{ui.RESET} {ui.DIM}(self-hosted){ui.RESET}")
|
|
291
|
+
ui.print_end()
|
|
292
|
+
return host, project_id
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _step_project_dir(cfg: dict) -> str:
|
|
296
|
+
ui.print_section("Step 3 — Local Project Directory")
|
|
297
|
+
ui.print_info(f"{ui.BOLD}This is the LOCAL folder on your Mac where the code lives.{ui.RESET}")
|
|
298
|
+
ui.print_info(f"{ui.BOLD}NOT a GitLab URL. NOT a Project ID.{ui.RESET}")
|
|
299
|
+
ui.print_info("")
|
|
300
|
+
ui.print_info(f"{ui.WHITE}If you haven't cloned the repo yet, do this first:{ui.RESET}")
|
|
301
|
+
ui.print_info(f" {ui.DIM}cd ~/Documents{ui.RESET}")
|
|
302
|
+
ui.print_info(f" {ui.DIM}git clone https://gitlab.com/your/project.git{ui.RESET}")
|
|
303
|
+
ui.print_info("")
|
|
304
|
+
ui.print_info(f"{ui.WHITE}Then paste the absolute path to that cloned folder, e.g.:{ui.RESET}")
|
|
305
|
+
ui.print_info(f" {ui.GREEN}/Users/yashkoladiya/Documents/dual-accounts{ui.RESET}")
|
|
306
|
+
ui.print_info(f" {ui.GREEN}~/work/myproject{ui.RESET} {ui.DIM}(tilde expands to home){ui.RESET}")
|
|
307
|
+
ui.print_info("")
|
|
308
|
+
ui.print_info(f"{ui.DIM}Tip: cd into the project folder before running, then press Enter to use it.{ui.RESET}")
|
|
309
|
+
|
|
310
|
+
saved = cfg.get("default_project_dir") or ""
|
|
311
|
+
if saved and Path(saved).is_dir():
|
|
312
|
+
ui.print_info("")
|
|
313
|
+
ui.print_info(f"Saved default: {ui.GREEN}{saved}{ui.RESET} {ui.DIM}(press Enter to reuse){ui.RESET}")
|
|
314
|
+
else:
|
|
315
|
+
ui.print_info("")
|
|
316
|
+
ui.print_info(f"Press {ui.BOLD}Enter{ui.RESET} to use current directory: {ui.GREEN}{Path.cwd()}{ui.RESET}")
|
|
317
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
318
|
+
|
|
319
|
+
while True:
|
|
320
|
+
raw = ui.ask_input("Local project dir")
|
|
321
|
+
if not raw:
|
|
322
|
+
project_dir = saved or str(Path.cwd())
|
|
323
|
+
else:
|
|
324
|
+
cleaned = _sanitize_path(raw)
|
|
325
|
+
# Reject URLs early with helpful message
|
|
326
|
+
if cleaned.startswith(("http://", "https://", "git@", "ssh://", "gitlab.com")):
|
|
327
|
+
ui.print_error("That's a URL, not a local folder.")
|
|
328
|
+
ui.print_info("Step 3 needs a path on your Mac (e.g. /Users/you/Documents/myproject).")
|
|
329
|
+
ui.print_info("Clone the repo first if you haven't:")
|
|
330
|
+
ui.print_info(f" {ui.DIM}git clone <url> ~/Documents/myproject{ui.RESET}")
|
|
331
|
+
continue
|
|
332
|
+
# Reject GitLab-ID-looking strings (relative path that doesn't exist on disk)
|
|
333
|
+
if (not cleaned.startswith(("/", "~", "."))
|
|
334
|
+
and "/" in cleaned
|
|
335
|
+
and not Path(cleaned).expanduser().exists()
|
|
336
|
+
and not Path.cwd().joinpath(cleaned).exists()):
|
|
337
|
+
ui.print_error("That looks like a GitLab Project ID, not a local folder.")
|
|
338
|
+
ui.print_info("Step 3 needs an absolute path like /Users/yashkoladiya/Documents/myproject")
|
|
339
|
+
continue
|
|
340
|
+
project_dir = cleaned
|
|
341
|
+
|
|
342
|
+
project_dir = str(Path(project_dir).expanduser().resolve())
|
|
343
|
+
if not Path(project_dir).is_dir():
|
|
344
|
+
ui.print_error(f"Directory does not exist: {project_dir}")
|
|
345
|
+
ui.print_info("Clone the repo first or check the path. Try again.")
|
|
346
|
+
continue
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
cfg["default_project_dir"] = project_dir
|
|
350
|
+
config.save(cfg)
|
|
351
|
+
ui.print_success(f"Local dir: {ui.GREEN}{project_dir}{ui.RESET}")
|
|
352
|
+
ui.print_end()
|
|
353
|
+
return project_dir
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _step_date_filter() -> str:
|
|
357
|
+
ui.print_section("Step 4 — Date Filter")
|
|
358
|
+
ui.print_info(f"Enter date to fetch bugs {ui.DIM}(format: YYYY-MM-DD){ui.RESET}")
|
|
359
|
+
ui.print_info(f"Press {ui.BOLD}Enter{ui.RESET} to fetch ALL open bugs")
|
|
360
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
361
|
+
date_input = ui.ask_input("Date")
|
|
362
|
+
if not date_input:
|
|
363
|
+
ui.print_success("Fetching ALL open bugs")
|
|
364
|
+
ui.print_end()
|
|
365
|
+
return None
|
|
366
|
+
try:
|
|
367
|
+
datetime.strptime(date_input, "%Y-%m-%d")
|
|
368
|
+
except ValueError:
|
|
369
|
+
ui.print_error("Invalid date format! Use YYYY-MM-DD")
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
ui.print_success(f"Filtering bugs created on {ui.BOLD}{date_input}{ui.RESET}")
|
|
372
|
+
ui.print_end()
|
|
373
|
+
return date_input
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ── Issue display + selection ──────────────────────────────────
|
|
377
|
+
|
|
378
|
+
def _display_issues(issues: list, project_id: str, skip_fixed: bool):
|
|
379
|
+
ui.print_section(f"Found {ui.CYAN}{len(issues)}{ui.WHITE} open bug issue(s)")
|
|
380
|
+
ui.print_divider()
|
|
381
|
+
for i, issue in enumerate(issues, 1):
|
|
382
|
+
labels = issue.get("labels", [])
|
|
383
|
+
created = issue.get("created_at", "")[:10]
|
|
384
|
+
priority, color = ui.get_priority(labels)
|
|
385
|
+
labels_str = ", ".join(labels) if labels else "—"
|
|
386
|
+
already = state.was_fixed(project_id, issue["iid"]) if skip_fixed else False
|
|
387
|
+
status_tag = f" {ui.GREEN}[FIXED]{ui.RESET}" if already else ""
|
|
388
|
+
|
|
389
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
390
|
+
print(f" {ui.BLUE}│ {ui.BOLD}{ui.WHITE}[{i}]{ui.RESET} {ui.BOLD}#{issue['iid']}{ui.RESET}"
|
|
391
|
+
f" {ui.WHITE}{issue['title']}{ui.RESET}{status_tag}")
|
|
392
|
+
print(f" {ui.BLUE}│ {ui.DIM}Created: {created} {ui.RESET}"
|
|
393
|
+
f"{color}Priority: {priority}{ui.RESET} {ui.DIM}Labels: {labels_str}{ui.RESET}")
|
|
394
|
+
ui.print_end()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _select_issues(issues: list, project_id: str, skip_fixed: bool) -> list:
|
|
398
|
+
ui.print_section("Step 6 — Select Issues to Fix")
|
|
399
|
+
ui.print_info(f"Enter numbers separated by commas {ui.DIM}(e.g., 1,3,5){ui.RESET}")
|
|
400
|
+
ui.print_info(f"Enter {ui.BOLD}'all'{ui.RESET} to fix all | {ui.BOLD}'unfixed'{ui.RESET} to skip already-fixed | {ui.BOLD}'q'{ui.RESET} to quit")
|
|
401
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
402
|
+
choice = ui.ask_input("Choice").lower()
|
|
403
|
+
|
|
404
|
+
if choice == "q":
|
|
405
|
+
ui.print_info("Bye!")
|
|
406
|
+
ui.print_end()
|
|
407
|
+
return []
|
|
408
|
+
|
|
409
|
+
if choice in ("all", "unfixed"):
|
|
410
|
+
if choice == "unfixed":
|
|
411
|
+
selected = [i for i in issues if not state.was_fixed(project_id, i["iid"])]
|
|
412
|
+
else:
|
|
413
|
+
selected = list(issues)
|
|
414
|
+
ui.print_success(f"Selected {len(selected)} issue(s)")
|
|
415
|
+
ui.print_warning("Changes will be LOCAL only (no commit/push)")
|
|
416
|
+
ui.print_end()
|
|
417
|
+
return selected
|
|
418
|
+
|
|
419
|
+
selected: list = []
|
|
420
|
+
invalid: list = []
|
|
421
|
+
for raw in choice.split(","):
|
|
422
|
+
raw = raw.strip()
|
|
423
|
+
if not raw:
|
|
424
|
+
continue
|
|
425
|
+
try:
|
|
426
|
+
idx = int(raw) - 1
|
|
427
|
+
except ValueError:
|
|
428
|
+
invalid.append(raw)
|
|
429
|
+
continue
|
|
430
|
+
if 0 <= idx < len(issues):
|
|
431
|
+
issue = issues[idx]
|
|
432
|
+
if skip_fixed and state.was_fixed(project_id, issue["iid"]):
|
|
433
|
+
ui.print_warning(f"Skipping #{issue['iid']} — already fixed previously")
|
|
434
|
+
continue
|
|
435
|
+
selected.append(issue)
|
|
436
|
+
else:
|
|
437
|
+
invalid.append(raw)
|
|
438
|
+
|
|
439
|
+
if invalid:
|
|
440
|
+
ui.print_warning(f"Ignored invalid entries: {', '.join(invalid)}")
|
|
441
|
+
|
|
442
|
+
if not selected:
|
|
443
|
+
ui.print_error("No valid issues selected.")
|
|
444
|
+
ui.print_end()
|
|
445
|
+
return []
|
|
446
|
+
|
|
447
|
+
ui.print_success(f"Selected {len(selected)} issue(s) to fix")
|
|
448
|
+
ui.print_warning("Changes will be LOCAL only (no commit/push)")
|
|
449
|
+
ui.print_end()
|
|
450
|
+
return selected
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ── Fix loop ───────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
def _print_parsed_preview(parsed):
|
|
456
|
+
sections: list = []
|
|
457
|
+
for name in ("description", "steps", "expected", "actual", "environment", "logs", "notes"):
|
|
458
|
+
if getattr(parsed, name, ""):
|
|
459
|
+
sections.append(name)
|
|
460
|
+
if sections:
|
|
461
|
+
ui.print_info(f"Parsed sections: {ui.CYAN}{', '.join(sections)}{ui.RESET}")
|
|
462
|
+
else:
|
|
463
|
+
ui.print_warning("No structured sections detected — using raw description")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _print_locator_preview(loc):
|
|
467
|
+
bits: list = []
|
|
468
|
+
if loc.files_mentioned:
|
|
469
|
+
bits.append(f"files={len(loc.files_mentioned)}")
|
|
470
|
+
if loc.stack_frames:
|
|
471
|
+
bits.append(f"frames={len(loc.stack_frames)}")
|
|
472
|
+
if loc.symbols:
|
|
473
|
+
bits.append(f"symbols={len(loc.symbols)}")
|
|
474
|
+
if loc.candidates:
|
|
475
|
+
bits.append(f"candidates={len(loc.candidates)}")
|
|
476
|
+
if loc.top_inline:
|
|
477
|
+
bits.append("inlined-top")
|
|
478
|
+
if bits:
|
|
479
|
+
ui.print_info(f"Locator: {ui.CYAN}{' '.join(bits)}{ui.RESET}")
|
|
480
|
+
else:
|
|
481
|
+
ui.print_warning("Locator: no hints — model will explore from scratch")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _print_budget(check, backend_name: str):
|
|
485
|
+
pct = check.estimated * 100 / max(check.per_issue_max, 1)
|
|
486
|
+
bar_len = 20
|
|
487
|
+
filled = min(bar_len, int(bar_len * pct / 100))
|
|
488
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
489
|
+
color = ui.GREEN if pct < 50 else (ui.YELLOW if pct < 90 else ui.RED)
|
|
490
|
+
ui.print_info(f"Budget [{backend_name}]: {color}{check.estimated:,}{ui.RESET} est tokens "
|
|
491
|
+
f"{ui.DIM}(cap {check.per_issue_max:,}){ui.RESET} {color}{bar}{ui.RESET}")
|
|
492
|
+
if check.session_used:
|
|
493
|
+
ui.print_info(f"Session used: {check.session_used:,} / {check.session_max:,}")
|
|
494
|
+
if check.daily_used:
|
|
495
|
+
ui.print_info(f"Today used: {check.daily_used:,} / {check.daily_max:,}")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _print_confidence(conf):
|
|
499
|
+
s = conf.final_score
|
|
500
|
+
bar_len = 20
|
|
501
|
+
filled = min(bar_len, int(bar_len * s))
|
|
502
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
503
|
+
color = ui.GREEN if s >= 0.8 else (ui.YELLOW if s >= 0.55 else ui.RED)
|
|
504
|
+
|
|
505
|
+
print(f" {ui.BLUE}│{ui.RESET}")
|
|
506
|
+
print(f" {ui.BLUE}│ {ui.BOLD}Confidence Report{ui.RESET}")
|
|
507
|
+
print(f" {ui.BLUE}│ Final score: {color}{s:.2f}{ui.RESET} {color}{bar}{ui.RESET} ({conf.label()})")
|
|
508
|
+
if conf.self_rating:
|
|
509
|
+
print(f" {ui.BLUE}│ Self-rating: {conf.self_rating}/10")
|
|
510
|
+
if conf.root_cause:
|
|
511
|
+
print(f" {ui.BLUE}│ Root cause: {ui.DIM}{conf.root_cause[:120]}{ui.RESET}")
|
|
512
|
+
print(f" {ui.BLUE}│ Diff focus: {conf.diff_focus:.2f}")
|
|
513
|
+
print(f" {ui.BLUE}│ File relevance: {conf.file_relevance:.2f}")
|
|
514
|
+
print(f" {ui.BLUE}│ Hedge density: {conf.hedge_density:.1%}")
|
|
515
|
+
print(f" {ui.BLUE}│ Tests run: {conf.tests_run}")
|
|
516
|
+
print(f" {ui.BLUE}│ Files changed: {len(conf.files_changed)} ({conf.lines_changed} lines)")
|
|
517
|
+
for note in conf.notes:
|
|
518
|
+
print(f" {ui.BLUE}│ {ui.YELLOW}!{ui.RESET} {note}")
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _fix_issues(selected: list, project_dir: str, project_id: str,
|
|
522
|
+
backend: Backend, cfg: dict) -> tuple:
|
|
523
|
+
fixed = failed = 0
|
|
524
|
+
session_tokens = 0
|
|
525
|
+
budgets = cfg.get("budgets") or {}
|
|
526
|
+
locator_cfg = cfg.get("locator") or {}
|
|
527
|
+
|
|
528
|
+
for i, issue in enumerate(selected, 1):
|
|
529
|
+
labels = issue.get("labels", [])
|
|
530
|
+
priority, color = ui.get_priority(labels)
|
|
531
|
+
|
|
532
|
+
ui.print_section(f"Fixing Issue [{i}/{len(selected)}]")
|
|
533
|
+
ui.print_info(f"#{issue['iid']} - {ui.BOLD}{issue['title']}{ui.RESET}")
|
|
534
|
+
ui.print_info(f"Priority: {color}{priority}{ui.RESET}")
|
|
535
|
+
|
|
536
|
+
parsed = parse_issue(issue)
|
|
537
|
+
_print_parsed_preview(parsed)
|
|
538
|
+
|
|
539
|
+
loc = locate(
|
|
540
|
+
parsed,
|
|
541
|
+
project_dir,
|
|
542
|
+
max_candidates=int(locator_cfg.get("max_candidates", 5)),
|
|
543
|
+
inline_top=bool(locator_cfg.get("inline_top_file", True)),
|
|
544
|
+
)
|
|
545
|
+
_print_locator_preview(loc)
|
|
546
|
+
|
|
547
|
+
prompt = build_prompt(parsed, locator=loc)
|
|
548
|
+
|
|
549
|
+
daily_used = state.get_daily_usage(backend.name)
|
|
550
|
+
check = budget.check_budget(
|
|
551
|
+
prompt, backend.name,
|
|
552
|
+
session_used=session_tokens, daily_used=daily_used,
|
|
553
|
+
budgets=budgets,
|
|
554
|
+
)
|
|
555
|
+
_print_budget(check, backend.name)
|
|
556
|
+
|
|
557
|
+
if not check.allowed:
|
|
558
|
+
ui.print_warning(f"Budget would be exceeded: {check.reason}")
|
|
559
|
+
ans = ui.ask_input("Run anyway? (y/N)").lower()
|
|
560
|
+
if ans != "y":
|
|
561
|
+
ui.print_warning(f"Skipped #{issue['iid']} due to budget.")
|
|
562
|
+
ui.print_end()
|
|
563
|
+
failed += 1
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
result: RunResult = backend.run(prompt, project_dir)
|
|
567
|
+
consumed = check.estimated # use estimate as proxy
|
|
568
|
+
|
|
569
|
+
print(f" {ui.BLUE}│ {ui.DIM}{'─' * 50}{ui.RESET}")
|
|
570
|
+
|
|
571
|
+
if result.timed_out:
|
|
572
|
+
ui.print_error("Backend timed out.")
|
|
573
|
+
|
|
574
|
+
success = result.ok
|
|
575
|
+
conf = evaluate_confidence(
|
|
576
|
+
result.stdout,
|
|
577
|
+
project_dir,
|
|
578
|
+
candidate_files=loc.candidates,
|
|
579
|
+
issue_keywords=loc.symbols + loc.files_mentioned,
|
|
580
|
+
)
|
|
581
|
+
_print_confidence(conf)
|
|
582
|
+
|
|
583
|
+
# Apply confidence floor: low confidence + no diff = treat as failure
|
|
584
|
+
if conf.final_score < 0.20 and not conf.files_changed:
|
|
585
|
+
success = False
|
|
586
|
+
|
|
587
|
+
session_tokens += consumed
|
|
588
|
+
state.record_usage(
|
|
589
|
+
backend_name=backend.name,
|
|
590
|
+
tokens=consumed,
|
|
591
|
+
project_id=project_id,
|
|
592
|
+
issue_iid=issue["iid"],
|
|
593
|
+
success=success,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
if success:
|
|
597
|
+
fixed += 1
|
|
598
|
+
ui.print_success(f"Done with #{issue['iid']} (confidence: {conf.label()})")
|
|
599
|
+
else:
|
|
600
|
+
failed += 1
|
|
601
|
+
ui.print_error(f"Failed on #{issue['iid']} (rc={result.returncode}, confidence: {conf.label()})")
|
|
602
|
+
|
|
603
|
+
ui.print_end()
|
|
604
|
+
|
|
605
|
+
if i < len(selected):
|
|
606
|
+
print(f"\n {ui.MAGENTA} Continue to next issue? (y/n): {ui.RESET}", end="")
|
|
607
|
+
cont = input().strip().lower()
|
|
608
|
+
if cont != "y":
|
|
609
|
+
ui.print_warning("Stopping early. Review your changes!")
|
|
610
|
+
break
|
|
611
|
+
|
|
612
|
+
return fixed, failed
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# ── Entry ──────────────────────────────────────────────────────
|
|
616
|
+
|
|
617
|
+
def main():
|
|
618
|
+
ui.print_banner()
|
|
619
|
+
cfg = config.load()
|
|
620
|
+
|
|
621
|
+
backend = _step_choose_backend(cfg)
|
|
622
|
+
|
|
623
|
+
token = _step_token()
|
|
624
|
+
host, project_id = _step_project_id(cfg)
|
|
625
|
+
project_dir = _step_project_dir(cfg)
|
|
626
|
+
date_filter = _step_date_filter()
|
|
627
|
+
|
|
628
|
+
ui.print_section("Step 5 — Fetching Issues from GitLab")
|
|
629
|
+
ui.print_info(f"Connecting to {ui.DIM}{host}{ui.RESET}...")
|
|
630
|
+
issues = fetch_bug_issues(token, project_id, date_filter, host=host)
|
|
631
|
+
if not issues:
|
|
632
|
+
ui.print_warning("No open bug issues found! Nothing to fix.")
|
|
633
|
+
ui.print_end()
|
|
634
|
+
return
|
|
635
|
+
ui.print_success(f"Fetched {len(issues)} bug issue(s)")
|
|
636
|
+
ui.print_end()
|
|
637
|
+
|
|
638
|
+
skip_fixed = bool(cfg.get("skip_already_fixed", True))
|
|
639
|
+
_display_issues(issues, project_id, skip_fixed)
|
|
640
|
+
selected = _select_issues(issues, project_id, skip_fixed)
|
|
641
|
+
if not selected:
|
|
642
|
+
return
|
|
643
|
+
|
|
644
|
+
fixed, failed = _fix_issues(selected, project_dir, project_id, backend, cfg)
|
|
645
|
+
ui.print_summary(fixed=fixed, failed=failed, total=len(selected))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
if __name__ == "__main__":
|
|
649
|
+
main()
|