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/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"
|