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/confidence.py ADDED
@@ -0,0 +1,232 @@
1
+ """Confidence scoring — combine model self-rating with diff metrics.
2
+
3
+ Sources of signal:
4
+ 1. FIX REPORT block in stdout (model self-rating + root cause)
5
+ 2. git diff inside project_dir (objective: lines changed, files changed)
6
+ 3. Hedge-word density in stdout (overrides self-rating when high)
7
+ 4. Relevance of changed files vs issue keywords / candidate list
8
+ """
9
+
10
+ import re
11
+ import subprocess
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+
16
+ # ── Patterns ───────────────────────────────────────────────────
17
+
18
+ REPORT_RE = re.compile(
19
+ r"===\s*FIX REPORT\s*===\s*\n"
20
+ r"(.*?)"
21
+ r"(?:===\s*END FIX REPORT\s*===|\Z)",
22
+ re.DOTALL | re.IGNORECASE,
23
+ )
24
+
25
+ FIELD_RE = re.compile(r"^\s*([A-Z_]+)\s*:\s*(.+?)\s*$", re.MULTILINE)
26
+
27
+ HEDGE_WORDS = [
28
+ "maybe", "might", "perhaps", "possibly", "i think",
29
+ "not sure", "unsure", "i guess", "probably", "likely",
30
+ "i'm not sure", "could be", "may be", "tentative",
31
+ ]
32
+
33
+ HEDGE_RE = re.compile(r"\b(" + "|".join(re.escape(w) for w in HEDGE_WORDS) + r")\b", re.IGNORECASE)
34
+
35
+
36
+ # ── Result ─────────────────────────────────────────────────────
37
+
38
+ @dataclass
39
+ class ConfidenceResult:
40
+ final_score: float = 0.0 # 0..1
41
+ self_rating: int = 0 # 0..10 (0 = not provided)
42
+ root_cause: str = ""
43
+ files_changed: list = field(default_factory=list)
44
+ lines_changed: int = 0
45
+ hedge_density: float = 0.0
46
+ diff_focus: float = 0.0 # 0..1
47
+ file_relevance: float = 0.0 # 0..1
48
+ tests_run: str = "unknown"
49
+ notes: list = field(default_factory=list)
50
+
51
+ def label(self) -> str:
52
+ s = self.final_score
53
+ if s >= 0.80:
54
+ return "High"
55
+ if s >= 0.55:
56
+ return "Medium"
57
+ if s >= 0.30:
58
+ return "Low"
59
+ return "Very Low"
60
+
61
+
62
+ # ── Parsing ────────────────────────────────────────────────────
63
+
64
+ def parse_fix_report(stdout: str) -> dict:
65
+ if not stdout:
66
+ return {}
67
+
68
+ block_match = REPORT_RE.search(stdout)
69
+ if block_match:
70
+ block = block_match.group(1)
71
+ else:
72
+ # Fallback: scan whole stdout for known fields.
73
+ block = stdout
74
+
75
+ fields: dict = {}
76
+ for m in FIELD_RE.finditer(block):
77
+ key = m.group(1).strip().upper()
78
+ val = m.group(2).strip()
79
+ fields[key] = val
80
+ return fields
81
+
82
+
83
+ def hedge_density(stdout: str) -> float:
84
+ if not stdout:
85
+ return 0.0
86
+ matches = HEDGE_RE.findall(stdout)
87
+ words = max(1, len(stdout.split()))
88
+ return min(1.0, len(matches) / max(words / 100, 1))
89
+
90
+
91
+ def git_diff_stats(project_dir: str) -> dict:
92
+ """Return {files: [paths], lines_added: int, lines_removed: int}."""
93
+ project = Path(project_dir)
94
+ if not (project / ".git").exists():
95
+ return {"files": [], "lines_added": 0, "lines_removed": 0, "is_git": False}
96
+
97
+ try:
98
+ # Both staged and unstaged
99
+ diff = subprocess.run(
100
+ ["git", "diff", "HEAD", "--numstat"],
101
+ cwd=project_dir, capture_output=True, text=True, timeout=15,
102
+ )
103
+ except (FileNotFoundError, subprocess.TimeoutExpired):
104
+ return {"files": [], "lines_added": 0, "lines_removed": 0, "is_git": True}
105
+
106
+ files: list = []
107
+ added = removed = 0
108
+ for line in diff.stdout.splitlines():
109
+ parts = line.split("\t")
110
+ if len(parts) != 3:
111
+ continue
112
+ a, r, path = parts
113
+ try:
114
+ added += int(a) if a != "-" else 0
115
+ removed += int(r) if r != "-" else 0
116
+ except ValueError:
117
+ pass
118
+ files.append(path)
119
+
120
+ return {
121
+ "files": files,
122
+ "lines_added": added,
123
+ "lines_removed": removed,
124
+ "is_git": True,
125
+ }
126
+
127
+
128
+ # ── Sub-scores ─────────────────────────────────────────────────
129
+
130
+ def diff_focus_score(diff_stats: dict) -> float:
131
+ """Smaller, focused diff = higher score. Empty diff = 0."""
132
+ if not diff_stats.get("is_git"):
133
+ return 0.5 # unknown, neutral
134
+ files = diff_stats.get("files", [])
135
+ if not files:
136
+ return 0.0
137
+ total = diff_stats.get("lines_added", 0) + diff_stats.get("lines_removed", 0)
138
+ if total == 0:
139
+ return 0.0
140
+ # 1.0 for ≤20 lines / 1-2 files; falls off
141
+ size_score = max(0.0, min(1.0, 1.0 - (total - 20) / 480))
142
+ file_score = max(0.0, min(1.0, 1.0 - (len(files) - 2) / 8))
143
+ return 0.6 * size_score + 0.4 * file_score
144
+
145
+
146
+ def file_relevance_score(diff_stats: dict, candidate_files: list, issue_keywords: list) -> float:
147
+ """Did the fix touch files we already identified as candidates / matching keywords?"""
148
+ files = diff_stats.get("files", [])
149
+ if not files:
150
+ return 0.0
151
+
152
+ candidates_norm = {Path(c).as_posix().lower() for c in (candidate_files or [])}
153
+ keywords_norm = [k.lower() for k in (issue_keywords or []) if k]
154
+
155
+ hits = 0
156
+ for f in files:
157
+ fp = Path(f).as_posix().lower()
158
+ if any(c in fp or fp in c for c in candidates_norm):
159
+ hits += 1
160
+ continue
161
+ if any(k in fp for k in keywords_norm):
162
+ hits += 1
163
+ return min(1.0, hits / len(files))
164
+
165
+
166
+ def parse_self_rating(fields: dict) -> int:
167
+ raw = fields.get("CONFIDENCE", "")
168
+ m = re.search(r"(\d+)\s*(?:/\s*10)?", raw or "")
169
+ if not m:
170
+ return 0
171
+ n = int(m.group(1))
172
+ return max(0, min(10, n))
173
+
174
+
175
+ # ── Public entrypoint ──────────────────────────────────────────
176
+
177
+ def evaluate(stdout: str, project_dir: str,
178
+ candidate_files: list = None,
179
+ issue_keywords: list = None) -> ConfidenceResult:
180
+ fields = parse_fix_report(stdout)
181
+ self_rating = parse_self_rating(fields)
182
+ root_cause = fields.get("ROOT_CAUSE", "")
183
+ tests_run = (fields.get("TESTS_RUN", "unknown") or "unknown").lower()
184
+
185
+ diff = git_diff_stats(project_dir)
186
+ files_changed = diff.get("files", [])
187
+ lines_changed = diff.get("lines_added", 0) + diff.get("lines_removed", 0)
188
+
189
+ hedge = hedge_density(stdout)
190
+ focus = diff_focus_score(diff)
191
+ relevance = file_relevance_score(diff, candidate_files or [], issue_keywords or [])
192
+
193
+ # Tests sub-score
194
+ if tests_run == "yes" or tests_run.startswith("pass"):
195
+ tests_score = 1.0
196
+ elif tests_run == "no":
197
+ tests_score = 0.4
198
+ elif tests_run == "n/a":
199
+ tests_score = 0.6
200
+ else:
201
+ tests_score = 0.5
202
+
203
+ # Weighted combo
204
+ final = (
205
+ 0.30 * focus +
206
+ 0.20 * relevance +
207
+ 0.20 * (self_rating / 10.0 if self_rating else 0.5) +
208
+ 0.15 * (1.0 - hedge) +
209
+ 0.15 * tests_score
210
+ )
211
+
212
+ notes: list = []
213
+ if not files_changed and diff.get("is_git"):
214
+ notes.append("No files changed — fix may not have been applied.")
215
+ final = min(final, 0.10)
216
+ if not diff.get("is_git"):
217
+ notes.append("Project is not a git repo — diff metrics unavailable.")
218
+ if hedge > 0.05:
219
+ notes.append(f"High hedge-word density ({hedge:.1%}) — model expressed uncertainty.")
220
+
221
+ return ConfidenceResult(
222
+ final_score=round(final, 3),
223
+ self_rating=self_rating,
224
+ root_cause=root_cause,
225
+ files_changed=files_changed,
226
+ lines_changed=lines_changed,
227
+ hedge_density=round(hedge, 3),
228
+ diff_focus=round(focus, 3),
229
+ file_relevance=round(relevance, 3),
230
+ tests_run=tests_run,
231
+ notes=notes,
232
+ )
bugfixer/config.py ADDED
@@ -0,0 +1,59 @@
1
+ """User config — ~/.bugfixer.json."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ CONFIG_PATH = Path.home() / ".bugfixer.json"
7
+
8
+ DEFAULT_CONFIG = {
9
+ "default_backend": "",
10
+ "default_project_id": "",
11
+ "default_project_dir": "",
12
+ "api": {
13
+ "preset": "",
14
+ "base_url": "",
15
+ "api_key": "",
16
+ "model": "",
17
+ },
18
+ "budgets": {
19
+ "session_max_tokens": 200_000,
20
+ "per_issue_max_tokens": 30_000,
21
+ "daily_max_tokens": 500_000,
22
+ },
23
+ "locator": {
24
+ "max_candidates": 5,
25
+ "inline_top_file": True,
26
+ "inline_max_lines": 250,
27
+ },
28
+ "skip_already_fixed": True,
29
+ }
30
+
31
+
32
+ def load() -> dict:
33
+ if not CONFIG_PATH.exists():
34
+ return _deep_copy(DEFAULT_CONFIG)
35
+ try:
36
+ data = json.loads(CONFIG_PATH.read_text())
37
+ except (json.JSONDecodeError, OSError):
38
+ return _deep_copy(DEFAULT_CONFIG)
39
+ return _merge(_deep_copy(DEFAULT_CONFIG), data)
40
+
41
+
42
+ def save(cfg: dict):
43
+ try:
44
+ CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
45
+ except OSError:
46
+ pass
47
+
48
+
49
+ def _deep_copy(d):
50
+ return json.loads(json.dumps(d))
51
+
52
+
53
+ def _merge(base: dict, override: dict) -> dict:
54
+ for k, v in (override or {}).items():
55
+ if isinstance(v, dict) and isinstance(base.get(k), dict):
56
+ base[k] = _merge(base[k], v)
57
+ else:
58
+ base[k] = v
59
+ return base
bugfixer/gitlab.py ADDED
@@ -0,0 +1,158 @@
1
+ """GitLab API client — fetch open bug issues with pagination.
2
+
3
+ Also exposes `parse_project_input` which accepts ANY of:
4
+ - Full HTTPS URL: https://gitlab.com/group/subgroup/project
5
+ - .git URL: https://gitlab.com/group/project.git
6
+ - Issues URL: https://gitlab.com/group/project/-/issues/42
7
+ - SSH URL: git@gitlab.com:group/project.git
8
+ - SCP-style: ssh://git@gitlab.com/group/project.git
9
+ - Self-hosted: https://gitlab.example.com/group/project
10
+ - Path only: group/subgroup/project
11
+ - Numeric ID: 12345
12
+ and returns (host, project_path).
13
+ """
14
+
15
+ import json
16
+ import re
17
+ import sys
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from datetime import datetime, timedelta
22
+
23
+ from . import ui
24
+
25
+ DEFAULT_HOST = "gitlab.com"
26
+ PER_PAGE = 100
27
+ MAX_PAGES = 20 # hard cap to avoid runaway loops
28
+
29
+
30
+ # ── URL parsing ────────────────────────────────────────────────
31
+
32
+ _SSH_RE = re.compile(r"^(?:ssh://)?(?:git@)?([^:/]+)[:/](.+?)(?:\.git)?/?$")
33
+
34
+
35
+ def parse_project_input(raw: str) -> tuple:
36
+ """Return (host, project_path). Defaults to gitlab.com when no host given.
37
+
38
+ Raises ValueError if the input clearly isn't a project reference.
39
+ """
40
+ if not raw or not raw.strip():
41
+ raise ValueError("empty input")
42
+
43
+ s = raw.strip()
44
+
45
+ # 1. Numeric ID — pass through, host defaults to gitlab.com
46
+ if s.isdigit():
47
+ return DEFAULT_HOST, s
48
+
49
+ # 2. http(s) URL
50
+ if s.startswith(("http://", "https://")):
51
+ parsed = urllib.parse.urlparse(s)
52
+ host = parsed.netloc
53
+ path = parsed.path.lstrip("/")
54
+ # Strip trailing .git
55
+ if path.endswith(".git"):
56
+ path = path[:-4]
57
+ # Strip GitLab UI suffixes: /-/issues, /-/blob/main, /-/tree/...
58
+ if "/-/" in path:
59
+ path = path.split("/-/")[0]
60
+ # Strip trailing slash
61
+ path = path.rstrip("/")
62
+ if not host or not path:
63
+ raise ValueError(f"could not parse URL: {raw}")
64
+ return host, path
65
+
66
+ # 3. SSH / SCP-style: git@host:group/project.git or ssh://git@host/group/project
67
+ m = _SSH_RE.match(s)
68
+ if m and (s.startswith(("ssh://", "git@")) or "@" in s):
69
+ host = m.group(1)
70
+ path = m.group(2).rstrip("/")
71
+ if path.endswith(".git"):
72
+ path = path[:-4]
73
+ return host, path
74
+
75
+ # 4. Plain path: group/project or group/subgroup/project
76
+ if "/" in s and not s.startswith(("/", ".", "~")):
77
+ # Strip leading "gitlab.com/" if user typed it without scheme
78
+ if s.startswith("gitlab.com/"):
79
+ return DEFAULT_HOST, s[len("gitlab.com/"):].rstrip("/")
80
+ return DEFAULT_HOST, s.rstrip("/")
81
+
82
+ raise ValueError(f"unrecognized project reference: {raw}")
83
+
84
+
85
+ # ── Date helpers ───────────────────────────────────────────────
86
+
87
+ def _next_day(date_str: str) -> str:
88
+ """Return YYYY-MM-DD for the day after the given date."""
89
+ d = datetime.strptime(date_str, "%Y-%m-%d") + timedelta(days=1)
90
+ return d.strftime("%Y-%m-%d")
91
+
92
+
93
+ # ── API ────────────────────────────────────────────────────────
94
+
95
+ def fetch_bug_issues(token: str, project_id: str, date_str: str = None,
96
+ host: str = DEFAULT_HOST) -> list:
97
+ """Fetch all open issues with 'Bug' label. Paginated, optionally filtered by date.
98
+
99
+ Date filter is inclusive: bugs created on `date_str` (UTC).
100
+ """
101
+ api_base = f"https://{host}/api/v4"
102
+ encoded_project = urllib.parse.quote(project_id, safe="")
103
+ base_url = f"{api_base}/projects/{encoded_project}/issues"
104
+
105
+ params = {
106
+ "labels": "Bug",
107
+ "state": "opened",
108
+ "per_page": str(PER_PAGE),
109
+ "order_by": "created_at",
110
+ "sort": "desc",
111
+ }
112
+
113
+ if date_str:
114
+ end_date = _next_day(date_str)
115
+ params["created_after"] = f"{date_str}T00:00:00Z"
116
+ params["created_before"] = f"{end_date}T00:00:00Z"
117
+
118
+ all_issues: list = []
119
+ page = 1
120
+
121
+ while page <= MAX_PAGES:
122
+ params["page"] = str(page)
123
+ url = f"{base_url}?{urllib.parse.urlencode(params)}"
124
+ req = urllib.request.Request(url, headers={"PRIVATE-TOKEN": token})
125
+
126
+ try:
127
+ with urllib.request.urlopen(req, timeout=30) as resp:
128
+ batch = json.loads(resp.read().decode())
129
+ next_page = resp.headers.get("X-Next-Page", "").strip()
130
+ except urllib.error.HTTPError as e:
131
+ ui.print_error(f"GitLab API error: {e.code} {e.reason}")
132
+ if e.code == 401:
133
+ ui.print_info("Token invalid or expired. Create a new one.")
134
+ elif e.code == 403:
135
+ ui.print_info("Token lacks required scope. Need 'api' or 'read_api'.")
136
+ elif e.code == 404:
137
+ ui.print_info(f"Project not found at https://{host}/{project_id}")
138
+ ui.print_info("Check that the URL is correct and your token has access.")
139
+ sys.exit(1)
140
+ except urllib.error.URLError as e:
141
+ ui.print_error(f"Network error: {e.reason}")
142
+ sys.exit(1)
143
+
144
+ if not batch:
145
+ break
146
+
147
+ all_issues.extend(batch)
148
+
149
+ if not next_page:
150
+ break
151
+
152
+ page = int(next_page)
153
+
154
+ return all_issues
155
+
156
+
157
+ # Backwards-compat alias (some tests / older code)
158
+ GITLAB_API_BASE = f"https://{DEFAULT_HOST}/api/v4"