fixfleet 0.4.0__tar.gz → 0.4.2__tar.gz
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.
- {fixfleet-0.4.0/fixfleet.egg-info → fixfleet-0.4.2}/PKG-INFO +14 -3
- {fixfleet-0.4.0 → fixfleet-0.4.2}/README.md +13 -2
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/__init__.py +1 -1
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/_subprocess.py +38 -6
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/api/openai_compat.py +33 -16
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/claude.py +3 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/codex.py +3 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/budget.py +10 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/cli.py +15 -1
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/config.py +3 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/json_api.py +56 -5
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/azure.py +42 -11
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/base.py +18 -3
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/bitbucket.py +19 -4
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/github.py +25 -14
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/gitlab.py +24 -2
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/jira.py +30 -12
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/linear.py +11 -6
- fixfleet-0.4.2/bugfixer/telemetry.py +152 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2/fixfleet.egg-info}/PKG-INFO +14 -3
- {fixfleet-0.4.0 → fixfleet-0.4.2}/fixfleet.egg-info/SOURCES.txt +1 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/pyproject.toml +1 -1
- {fixfleet-0.4.0 → fixfleet-0.4.2}/tests/test_all.py +3 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/tests/test_providers_qa.py +66 -2
- {fixfleet-0.4.0 → fixfleet-0.4.2}/LICENSE +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/__init__.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/api/__init__.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/base.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/__init__.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/aider.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/cursor.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/gemini.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/qwen.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/registry.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/confidence.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/gitlab.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/locator.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/parser.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/prompt.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/__init__.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/registry.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/state.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/ui.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/fixfleet.egg-info/dependency_links.txt +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/fixfleet.egg-info/entry_points.txt +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/fixfleet.egg-info/top_level.txt +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixfleet
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Auto-fix bugs from GitHub, GitLab, Jira, Linear, Bitbucket, or Azure DevOps with AI agents — bring your own CLI or free API.
|
|
5
5
|
Author: Yash Koladiya
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -405,12 +405,23 @@ No. FixFleet runs **100% locally** on your machine. No FixFleet servers, no Azur
|
|
|
405
405
|
|
|
406
406
|
</details>
|
|
407
407
|
|
|
408
|
+
<details>
|
|
409
|
+
<summary><b>Does FixFleet collect any data?</b></summary>
|
|
410
|
+
|
|
411
|
+
Only anonymous usage analytics (via Google Analytics): event names and coarse metadata like provider key, backend name, and success/failure — **never your code, tokens, project URLs, or issue content**. Used purely to find bugs and see which trackers/backends need attention.
|
|
412
|
+
|
|
413
|
+
Opt out anytime:
|
|
414
|
+
- CLI: `fixfleet --config-set telemetry.enabled=false` (or set `DO_NOT_TRACK=1`)
|
|
415
|
+
- VSCode: uncheck `fixfleet.telemetry` in settings (the global VS Code telemetry setting is respected too)
|
|
416
|
+
|
|
417
|
+
</details>
|
|
418
|
+
|
|
408
419
|
<details>
|
|
409
420
|
<summary><b>What's the difference between the VSCode extension and the CLI?</b></summary>
|
|
410
421
|
|
|
411
422
|
Same engine, two interfaces:
|
|
412
|
-
- **CLI** (`pip install fixfleet`) — interactive terminal flow, beautiful styled output
|
|
413
|
-
- **VSCode extension** — premium UI sidebar + click-to-fix workflow, calls the CLI under the hood
|
|
423
|
+
- **CLI** (`pip install fixfleet`) — interactive terminal flow, beautiful styled output. The guided interactive mode currently supports GitLab; for the other trackers use the JSON flags (`fixfleet --list-bugs-json --provider github ...`) or the extension.
|
|
424
|
+
- **VSCode extension** — premium UI sidebar + click-to-fix workflow for all 6 trackers, calls the CLI under the hood
|
|
414
425
|
|
|
415
426
|
Install both if you want flexibility. Extension auto-installs the CLI on first run if missing.
|
|
416
427
|
|
|
@@ -377,12 +377,23 @@ No. FixFleet runs **100% locally** on your machine. No FixFleet servers, no Azur
|
|
|
377
377
|
|
|
378
378
|
</details>
|
|
379
379
|
|
|
380
|
+
<details>
|
|
381
|
+
<summary><b>Does FixFleet collect any data?</b></summary>
|
|
382
|
+
|
|
383
|
+
Only anonymous usage analytics (via Google Analytics): event names and coarse metadata like provider key, backend name, and success/failure — **never your code, tokens, project URLs, or issue content**. Used purely to find bugs and see which trackers/backends need attention.
|
|
384
|
+
|
|
385
|
+
Opt out anytime:
|
|
386
|
+
- CLI: `fixfleet --config-set telemetry.enabled=false` (or set `DO_NOT_TRACK=1`)
|
|
387
|
+
- VSCode: uncheck `fixfleet.telemetry` in settings (the global VS Code telemetry setting is respected too)
|
|
388
|
+
|
|
389
|
+
</details>
|
|
390
|
+
|
|
380
391
|
<details>
|
|
381
392
|
<summary><b>What's the difference between the VSCode extension and the CLI?</b></summary>
|
|
382
393
|
|
|
383
394
|
Same engine, two interfaces:
|
|
384
|
-
- **CLI** (`pip install fixfleet`) — interactive terminal flow, beautiful styled output
|
|
385
|
-
- **VSCode extension** — premium UI sidebar + click-to-fix workflow, calls the CLI under the hood
|
|
395
|
+
- **CLI** (`pip install fixfleet`) — interactive terminal flow, beautiful styled output. The guided interactive mode currently supports GitLab; for the other trackers use the JSON flags (`fixfleet --list-bugs-json --provider github ...`) or the extension.
|
|
396
|
+
- **VSCode extension** — premium UI sidebar + click-to-fix workflow for all 6 trackers, calls the CLI under the hood
|
|
386
397
|
|
|
387
398
|
Install both if you want flexibility. Extension auto-installs the CLI on first run if missing.
|
|
388
399
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Shared subprocess runner with stdout tee (live print + capture)."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import signal
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
import threading
|
|
@@ -9,6 +10,30 @@ from typing import List
|
|
|
9
10
|
|
|
10
11
|
from .base import RunResult
|
|
11
12
|
|
|
13
|
+
_IS_POSIX = os.name == "posix"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _kill_tree(proc: subprocess.Popen):
|
|
17
|
+
"""Kill the child and (on POSIX) its whole process group, then reap it.
|
|
18
|
+
|
|
19
|
+
Agentic CLIs fork grandchildren (shell tools, git, sandbox workers); killing
|
|
20
|
+
only the direct child leaves those running and still mutating the repo.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
if _IS_POSIX:
|
|
24
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
25
|
+
else:
|
|
26
|
+
proc.kill()
|
|
27
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
28
|
+
try:
|
|
29
|
+
proc.kill()
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
try:
|
|
33
|
+
proc.wait(timeout=5)
|
|
34
|
+
except subprocess.TimeoutExpired:
|
|
35
|
+
pass
|
|
36
|
+
|
|
12
37
|
|
|
13
38
|
def run_with_tee(cmd: List[str], cwd: str, timeout: int = 600,
|
|
14
39
|
stdin_data: str = None, env: dict = None) -> RunResult:
|
|
@@ -25,18 +50,25 @@ def run_with_tee(cmd: List[str], cwd: str, timeout: int = 600,
|
|
|
25
50
|
stdout=subprocess.PIPE,
|
|
26
51
|
stderr=subprocess.PIPE,
|
|
27
52
|
text=True,
|
|
53
|
+
encoding="utf-8",
|
|
54
|
+
errors="replace",
|
|
28
55
|
bufsize=1,
|
|
29
56
|
env={**os.environ, **(env or {})},
|
|
57
|
+
start_new_session=_IS_POSIX, # own process group so timeout can kill the whole tree
|
|
30
58
|
)
|
|
31
59
|
except FileNotFoundError as e:
|
|
32
60
|
return RunResult(returncode=127, stderr=str(e))
|
|
33
61
|
|
|
34
62
|
if stdin_data is not None:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
# Write on a thread — a large payload can fill the pipe buffer and
|
|
64
|
+
# deadlock against the child's stdout otherwise.
|
|
65
|
+
def writer():
|
|
66
|
+
try:
|
|
67
|
+
proc.stdin.write(stdin_data)
|
|
68
|
+
proc.stdin.close()
|
|
69
|
+
except (BrokenPipeError, OSError):
|
|
70
|
+
pass
|
|
71
|
+
threading.Thread(target=writer, daemon=True).start()
|
|
40
72
|
|
|
41
73
|
def reader(stream, sink_list, dest):
|
|
42
74
|
try:
|
|
@@ -61,7 +93,7 @@ def run_with_tee(cmd: List[str], cwd: str, timeout: int = 600,
|
|
|
61
93
|
if rc is not None:
|
|
62
94
|
break
|
|
63
95
|
if time.time() > deadline:
|
|
64
|
-
proc
|
|
96
|
+
_kill_tree(proc)
|
|
65
97
|
t_out.join(timeout=2)
|
|
66
98
|
t_err.join(timeout=2)
|
|
67
99
|
return RunResult(
|
|
@@ -10,11 +10,11 @@ No agentic tool-use loop — keeps token usage minimal.
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import re
|
|
13
|
+
import socket
|
|
13
14
|
import subprocess
|
|
14
15
|
import sys
|
|
15
16
|
import urllib.error
|
|
16
17
|
import urllib.request
|
|
17
|
-
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
from ..base import Backend, RunResult
|
|
20
20
|
|
|
@@ -66,15 +66,12 @@ class OpenAICompatBackend(Backend):
|
|
|
66
66
|
"max_tokens": self.max_output_tokens,
|
|
67
67
|
}
|
|
68
68
|
data = json.dumps(body).encode()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
},
|
|
76
|
-
method="POST",
|
|
77
|
-
)
|
|
69
|
+
headers = {"Content-Type": "application/json"}
|
|
70
|
+
if self.api_key:
|
|
71
|
+
# Omit the header entirely when no key (Ollama/LM Studio) — some
|
|
72
|
+
# strict gateways reject an empty Authorization value.
|
|
73
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
74
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
78
75
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
79
76
|
return json.loads(resp.read().decode())
|
|
80
77
|
|
|
@@ -90,13 +87,19 @@ class OpenAICompatBackend(Backend):
|
|
|
90
87
|
err_body = e.read().decode(errors="ignore")
|
|
91
88
|
return RunResult(returncode=e.code, stderr=f"HTTP {e.code}: {err_body[:500]}")
|
|
92
89
|
except urllib.error.URLError as e:
|
|
90
|
+
if isinstance(getattr(e, "reason", None), socket.timeout):
|
|
91
|
+
return RunResult(returncode=2, stderr=f"Request timed out after {timeout}s", timed_out=True)
|
|
93
92
|
return RunResult(returncode=2, stderr=f"Network error: {e.reason}")
|
|
93
|
+
except socket.timeout:
|
|
94
|
+
return RunResult(returncode=2, stderr=f"Request timed out after {timeout}s", timed_out=True)
|
|
94
95
|
except Exception as e:
|
|
95
96
|
return RunResult(returncode=3, stderr=f"Unexpected error: {e}")
|
|
96
97
|
|
|
97
98
|
try:
|
|
98
99
|
content = response["choices"][0]["message"]["content"] or ""
|
|
99
|
-
except (KeyError, IndexError):
|
|
100
|
+
except (KeyError, IndexError, TypeError):
|
|
101
|
+
# TypeError covers "choices": null — some proxies return error
|
|
102
|
+
# payloads with HTTP 200.
|
|
100
103
|
return RunResult(returncode=4, stderr=f"Bad response shape: {json.dumps(response)[:500]}")
|
|
101
104
|
|
|
102
105
|
# Echo to user so they can read the model's reasoning live.
|
|
@@ -138,18 +141,22 @@ def _extract_diff(text: str) -> str:
|
|
|
138
141
|
"""Pull the first fenced diff block out of the model output."""
|
|
139
142
|
matches = DIFF_FENCE_RE.findall(text)
|
|
140
143
|
for m in matches:
|
|
141
|
-
if "---" in m and "+++" in m and "@@" in m:
|
|
144
|
+
if ("---" in m and "+++" in m and "@@" in m) or m.lstrip().startswith("diff --git"):
|
|
142
145
|
return m.strip() + "\n"
|
|
143
146
|
# Fallback: maybe model emitted raw diff without fences
|
|
144
|
-
|
|
147
|
+
stripped = text.lstrip()
|
|
148
|
+
if stripped.startswith("diff --git") or (stripped.startswith("---") and "+++" in text):
|
|
145
149
|
return text.strip() + "\n"
|
|
146
150
|
return ""
|
|
147
151
|
|
|
148
152
|
|
|
149
153
|
def _apply_diff(diff_text: str, project_dir: str) -> tuple:
|
|
150
|
-
"""Try `git apply`, fall back to `patch -p1`. Return (rc, message).
|
|
151
|
-
project = Path(project_dir)
|
|
154
|
+
"""Try `git apply`, fall back to `patch -p1`. Return (rc, message).
|
|
152
155
|
|
|
156
|
+
Both paths are atomic: git apply is all-or-nothing by design, and the
|
|
157
|
+
patch fallback dry-runs first so a failing hunk never leaves the repo
|
|
158
|
+
half-patched.
|
|
159
|
+
"""
|
|
153
160
|
# Use stdin to feed the diff
|
|
154
161
|
try:
|
|
155
162
|
result = subprocess.run(
|
|
@@ -166,8 +173,18 @@ def _apply_diff(diff_text: str, project_dir: str) -> tuple:
|
|
|
166
173
|
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
167
174
|
git_err = str(e)
|
|
168
175
|
|
|
169
|
-
# Try patch as fallback
|
|
176
|
+
# Try patch as fallback — dry-run first to keep it all-or-nothing.
|
|
170
177
|
try:
|
|
178
|
+
dry = subprocess.run(
|
|
179
|
+
["patch", "-p1", "--forward", "--dry-run"],
|
|
180
|
+
input=diff_text,
|
|
181
|
+
cwd=project_dir,
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
timeout=30,
|
|
185
|
+
)
|
|
186
|
+
if dry.returncode != 0:
|
|
187
|
+
return dry.returncode, f"git apply: {git_err}; patch (dry-run): {dry.stderr or dry.stdout}"
|
|
171
188
|
result = subprocess.run(
|
|
172
189
|
["patch", "-p1", "--forward"],
|
|
173
190
|
input=diff_text,
|
|
@@ -12,6 +12,9 @@ class ClaudeCLIBackend(Backend):
|
|
|
12
12
|
DEFAULT_ALLOWED_TOOLS = "Read,Edit,Write,Grep,Glob,Bash"
|
|
13
13
|
|
|
14
14
|
def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
|
|
15
|
+
if prompt.startswith("-"):
|
|
16
|
+
# Positional argv token — a leading dash would parse as a flag.
|
|
17
|
+
prompt = " " + prompt
|
|
15
18
|
cmd = [
|
|
16
19
|
"claude",
|
|
17
20
|
"--print",
|
|
@@ -10,6 +10,9 @@ class CodexCLIBackend(Backend):
|
|
|
10
10
|
requires_binary = "codex"
|
|
11
11
|
|
|
12
12
|
def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
|
|
13
|
+
if prompt.startswith("-"):
|
|
14
|
+
# Positional argv token — a leading dash would parse as a flag.
|
|
15
|
+
prompt = " " + prompt
|
|
13
16
|
# `codex exec` is the non-interactive mode; --full-auto auto-approves edits within sandbox.
|
|
14
17
|
cmd = [
|
|
15
18
|
"codex",
|
|
@@ -85,10 +85,20 @@ class BudgetCheck:
|
|
|
85
85
|
reason: str = ""
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _to_int(value, fallback: int) -> int:
|
|
89
|
+
"""Coerce config values that may have been stored as strings."""
|
|
90
|
+
try:
|
|
91
|
+
return int(value)
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
return fallback
|
|
94
|
+
|
|
95
|
+
|
|
88
96
|
def check_budget(prompt: str, backend_name: str,
|
|
89
97
|
session_used: int, daily_used: int,
|
|
90
98
|
budgets: dict = None) -> BudgetCheck:
|
|
91
99
|
b = {**DEFAULT_BUDGETS, **(budgets or {})}
|
|
100
|
+
for key, default in DEFAULT_BUDGETS.items():
|
|
101
|
+
b[key] = _to_int(b.get(key), default)
|
|
92
102
|
estimated = estimate_total_cost(prompt, backend_name)
|
|
93
103
|
|
|
94
104
|
reasons: list = []
|
|
@@ -5,7 +5,7 @@ import sys
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
-
from . import budget, config, state, ui
|
|
8
|
+
from . import budget, config, state, telemetry, ui
|
|
9
9
|
from .backends.base import Backend, RunResult
|
|
10
10
|
from .backends.registry import (
|
|
11
11
|
API_PRESETS,
|
|
@@ -569,6 +569,7 @@ def _fix_issues(selected: list, project_dir: str, project_id: str,
|
|
|
569
569
|
failed += 1
|
|
570
570
|
continue
|
|
571
571
|
|
|
572
|
+
telemetry.track("fix_started", {"provider": "gitlab", "backend": backend.name})
|
|
572
573
|
result: RunResult = backend.run(prompt, project_dir)
|
|
573
574
|
consumed = check.estimated # use estimate as proxy
|
|
574
575
|
|
|
@@ -590,6 +591,15 @@ def _fix_issues(selected: list, project_dir: str, project_id: str,
|
|
|
590
591
|
if conf.final_score < 0.20 and not conf.files_changed:
|
|
591
592
|
success = False
|
|
592
593
|
|
|
594
|
+
telemetry.track("fix_completed", {
|
|
595
|
+
"provider": "gitlab",
|
|
596
|
+
"backend": backend.name,
|
|
597
|
+
"success": success,
|
|
598
|
+
"timed_out": result.timed_out,
|
|
599
|
+
"confidence": conf.label(),
|
|
600
|
+
"files_changed": len(conf.files_changed),
|
|
601
|
+
})
|
|
602
|
+
|
|
593
603
|
session_tokens += consumed
|
|
594
604
|
state.record_usage(
|
|
595
605
|
backend_name=backend.name,
|
|
@@ -636,20 +646,24 @@ def main():
|
|
|
636
646
|
try:
|
|
637
647
|
issues = fetch_bug_issues(token, project_id, date_filter, host=host)
|
|
638
648
|
except GitLabAuthError as e:
|
|
649
|
+
telemetry.track("provider_error", {"provider": "gitlab", "code": e.code})
|
|
639
650
|
ui.print_error(e.message)
|
|
640
651
|
ui.print_info("Open https://gitlab.com/-/user_settings/personal_access_tokens to create a new token.")
|
|
641
652
|
ui.print_end()
|
|
642
653
|
sys.exit(1)
|
|
643
654
|
except GitLabNotFoundError as e:
|
|
655
|
+
telemetry.track("provider_error", {"provider": "gitlab", "code": e.code})
|
|
644
656
|
ui.print_error(e.message)
|
|
645
657
|
ui.print_end()
|
|
646
658
|
sys.exit(1)
|
|
647
659
|
except GitLabNetworkError as e:
|
|
660
|
+
telemetry.track("provider_error", {"provider": "gitlab", "code": e.code})
|
|
648
661
|
ui.print_error(e.message)
|
|
649
662
|
ui.print_info("Check your internet connection and try again.")
|
|
650
663
|
ui.print_end()
|
|
651
664
|
sys.exit(1)
|
|
652
665
|
except GitLabError as e:
|
|
666
|
+
telemetry.track("provider_error", {"provider": "gitlab", "code": e.code})
|
|
653
667
|
ui.print_error(e.message)
|
|
654
668
|
ui.print_end()
|
|
655
669
|
sys.exit(1)
|
|
@@ -11,7 +11,7 @@ import json
|
|
|
11
11
|
import sys
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
-
from . import __version__, budget, config, state
|
|
14
|
+
from . import __version__, budget, config, state, telemetry
|
|
15
15
|
from .backends.base import RunResult
|
|
16
16
|
from .backends.registry import (
|
|
17
17
|
API_PRESETS,
|
|
@@ -139,12 +139,21 @@ def cmd_list_bugs_json(args):
|
|
|
139
139
|
date_to=getattr(args, "date_to", None),
|
|
140
140
|
)
|
|
141
141
|
except ProviderNotImplementedError as e:
|
|
142
|
+
telemetry.track("provider_error", {"provider": provider.key, "code": e.code})
|
|
142
143
|
_err_from_gitlab(e)
|
|
143
144
|
except ProviderError as e:
|
|
145
|
+
telemetry.track("provider_error", {"provider": provider.key, "code": e.code})
|
|
144
146
|
_err_from_gitlab(e)
|
|
145
147
|
except Exception as e:
|
|
148
|
+
telemetry.track("unexpected_error", {"where": "list_bugs", "type": type(e).__name__})
|
|
146
149
|
_err(f"unexpected error: {e}", error_code="unexpected")
|
|
147
150
|
|
|
151
|
+
telemetry.track("bugs_listed", {
|
|
152
|
+
"provider": provider.key,
|
|
153
|
+
"count": len(issues),
|
|
154
|
+
"date_filter": bool(args.date or getattr(args, "date_from", None) or getattr(args, "date_to", None)),
|
|
155
|
+
})
|
|
156
|
+
|
|
148
157
|
payload_issues = []
|
|
149
158
|
for i in issues:
|
|
150
159
|
parsed = parse_issue(i)
|
|
@@ -238,8 +247,10 @@ def cmd_fix_issue(args):
|
|
|
238
247
|
try:
|
|
239
248
|
issues = provider.fetch_bugs(token=token, project_id=project_id, host=host)
|
|
240
249
|
except ProviderNotImplementedError as e:
|
|
250
|
+
telemetry.track("provider_error", {"provider": provider.key, "code": e.code})
|
|
241
251
|
_err_from_gitlab(e)
|
|
242
252
|
except ProviderError as e:
|
|
253
|
+
telemetry.track("provider_error", {"provider": provider.key, "code": e.code})
|
|
243
254
|
_err_from_gitlab(e)
|
|
244
255
|
target = next(
|
|
245
256
|
(i for i in issues if str(i.get("iid")) == str(issue_iid)),
|
|
@@ -252,14 +263,31 @@ def cmd_fix_issue(args):
|
|
|
252
263
|
loc = locate(parsed, project_dir)
|
|
253
264
|
prompt = build_prompt(parsed, locator=loc)
|
|
254
265
|
|
|
255
|
-
# Budget check
|
|
266
|
+
# Budget check — enforced unless --force is passed.
|
|
267
|
+
cfg_budgets = (config.load().get("budgets") or {})
|
|
256
268
|
daily_used = state.get_daily_usage(backend.name)
|
|
257
269
|
check = budget.check_budget(
|
|
258
270
|
prompt, backend.name,
|
|
259
271
|
session_used=0, daily_used=daily_used,
|
|
272
|
+
budgets=cfg_budgets,
|
|
260
273
|
)
|
|
274
|
+
if not check.allowed and not getattr(args, "force", False):
|
|
275
|
+
telemetry.track("budget_blocked", {"backend": backend.name})
|
|
276
|
+
_emit({
|
|
277
|
+
"ok": False,
|
|
278
|
+
"code": "budget_exceeded",
|
|
279
|
+
"error": f"Budget would be exceeded: {check.reason}. Pass --force to run anyway.",
|
|
280
|
+
"budget": {
|
|
281
|
+
"estimated": check.estimated,
|
|
282
|
+
"per_issue_max": check.per_issue_max,
|
|
283
|
+
"daily_used": check.daily_used,
|
|
284
|
+
"daily_max": check.daily_max,
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
sys.exit(1)
|
|
261
288
|
|
|
262
289
|
# Run backend
|
|
290
|
+
telemetry.track("fix_started", {"provider": provider.key, "backend": backend.name})
|
|
263
291
|
result: RunResult = backend.run(prompt, project_dir)
|
|
264
292
|
conf = evaluate_confidence(
|
|
265
293
|
result.stdout, project_dir,
|
|
@@ -268,6 +296,14 @@ def cmd_fix_issue(args):
|
|
|
268
296
|
)
|
|
269
297
|
|
|
270
298
|
success = result.ok and (conf.final_score >= 0.20 or conf.files_changed)
|
|
299
|
+
telemetry.track("fix_completed", {
|
|
300
|
+
"provider": provider.key,
|
|
301
|
+
"backend": backend.name,
|
|
302
|
+
"success": success,
|
|
303
|
+
"timed_out": result.timed_out,
|
|
304
|
+
"confidence": conf.label(),
|
|
305
|
+
"files_changed": len(conf.files_changed),
|
|
306
|
+
})
|
|
271
307
|
state.record_usage(
|
|
272
308
|
backend_name=backend.name,
|
|
273
309
|
tokens=check.estimated,
|
|
@@ -341,12 +377,18 @@ def cmd_config_set(args):
|
|
|
341
377
|
if "=" not in pair:
|
|
342
378
|
_err(f"invalid --config-set entry: {pair}")
|
|
343
379
|
key, value = pair.split("=", 1)
|
|
380
|
+
# Coerce to native JSON types (int, float, bool, null) so numeric
|
|
381
|
+
# settings like budgets don't get stored as strings.
|
|
382
|
+
try:
|
|
383
|
+
parsed_value = json.loads(value)
|
|
384
|
+
except (json.JSONDecodeError, ValueError):
|
|
385
|
+
parsed_value = value
|
|
344
386
|
# Support nested keys via dot notation: api.base_url=...
|
|
345
387
|
parts = key.split(".")
|
|
346
388
|
target = cfg
|
|
347
389
|
for p in parts[:-1]:
|
|
348
390
|
target = target.setdefault(p, {})
|
|
349
|
-
target[parts[-1]] =
|
|
391
|
+
target[parts[-1]] = parsed_value
|
|
350
392
|
|
|
351
393
|
config.save(cfg)
|
|
352
394
|
_emit({"ok": True, "saved": list(args.config_set)})
|
|
@@ -376,9 +418,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
376
418
|
p.add_argument("--token", help="Issue-tracker token (or set BUGFIXER_TOKEN env var)")
|
|
377
419
|
p.add_argument("--project-url", help="Project URL or ID")
|
|
378
420
|
p.add_argument("--project-dir", help="Local project directory")
|
|
379
|
-
p.add_argument("--provider", default=
|
|
380
|
-
help="Issue-tracker provider: gitlab
|
|
421
|
+
p.add_argument("--provider", default=None,
|
|
422
|
+
help="Issue-tracker provider: gitlab, github, jira, linear, bitbucket, azure "
|
|
423
|
+
"(default: auto-detect from --project-url, falling back to gitlab)")
|
|
381
424
|
p.add_argument("--backend", help="AI backend name: claude, codex, gemini, cursor, aider, qwen, openai_compat")
|
|
425
|
+
p.add_argument("--force", action="store_true",
|
|
426
|
+
help="Run --fix-issue even when the token budget would be exceeded")
|
|
382
427
|
p.add_argument("--date", help="Filter bugs created on YYYY-MM-DD (single day)")
|
|
383
428
|
p.add_argument("--date-from", dest="date_from", help="Filter bugs created on/after YYYY-MM-DD")
|
|
384
429
|
p.add_argument("--date-to", dest="date_to", help="Filter bugs created on/before YYYY-MM-DD")
|
|
@@ -398,25 +443,31 @@ def main():
|
|
|
398
443
|
args.backend = args.backend or os.environ.get("BUGFIXER_BACKEND", "")
|
|
399
444
|
|
|
400
445
|
if args.backends_json:
|
|
446
|
+
telemetry.track("cli_start", {"mode": "backends_json"})
|
|
401
447
|
cmd_backends_json()
|
|
402
448
|
return
|
|
403
449
|
if args.providers_json:
|
|
404
450
|
cmd_providers_json()
|
|
405
451
|
return
|
|
406
452
|
if args.list_bugs_json:
|
|
453
|
+
telemetry.track("cli_start", {"mode": "list_bugs_json"})
|
|
407
454
|
cmd_list_bugs_json(args)
|
|
408
455
|
return
|
|
409
456
|
if args.fix_issue is not None:
|
|
457
|
+
telemetry.track("cli_start", {"mode": "fix_issue"})
|
|
410
458
|
cmd_fix_issue(args)
|
|
411
459
|
return
|
|
412
460
|
if args.config_get:
|
|
413
461
|
cmd_config_get()
|
|
414
462
|
return
|
|
415
463
|
if args.config_set:
|
|
464
|
+
telemetry.track("config_set", {"keys": ",".join(p.split("=")[0] for p in args.config_set)})
|
|
416
465
|
cmd_config_set(args)
|
|
417
466
|
return
|
|
418
467
|
|
|
419
468
|
# No JSON flag → fall through to interactive CLI
|
|
469
|
+
telemetry.maybe_show_notice()
|
|
470
|
+
telemetry.track("cli_start", {"mode": "interactive"})
|
|
420
471
|
from .cli import main as interactive_main
|
|
421
472
|
interactive_main()
|
|
422
473
|
|
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
import urllib.error
|
|
13
13
|
import urllib.parse
|
|
14
14
|
import urllib.request
|
|
15
|
+
from datetime import datetime, timedelta
|
|
15
16
|
from typing import Optional
|
|
16
17
|
|
|
17
18
|
from .base import (
|
|
@@ -28,6 +29,15 @@ API_HOST = "dev.azure.com"
|
|
|
28
29
|
MAX_IDS_PER_REQUEST = 200
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def _next_day(date_str: str) -> str:
|
|
33
|
+
"""YYYY-MM-DD → next day, for exclusive upper bounds."""
|
|
34
|
+
try:
|
|
35
|
+
d = datetime.strptime(date_str, "%Y-%m-%d")
|
|
36
|
+
except ValueError:
|
|
37
|
+
return date_str
|
|
38
|
+
return (d + timedelta(days=1)).strftime("%Y-%m-%d")
|
|
39
|
+
|
|
40
|
+
|
|
31
41
|
class AzureDevOpsProvider(Provider):
|
|
32
42
|
key = "azure"
|
|
33
43
|
display_name = "Azure DevOps"
|
|
@@ -103,14 +113,21 @@ class AzureDevOpsProvider(Provider):
|
|
|
103
113
|
]
|
|
104
114
|
if date_from:
|
|
105
115
|
wiql_parts.append(f" AND [System.CreatedDate] >= '{date_from}T00:00:00Z'")
|
|
116
|
+
if date_to:
|
|
117
|
+
wiql_parts.append(f" AND [System.CreatedDate] < '{_next_day(date_to)}T00:00:00Z'")
|
|
106
118
|
elif date_str:
|
|
119
|
+
# Single-day window, matching GitLab's --date semantics.
|
|
107
120
|
wiql_parts.append(f" AND [System.CreatedDate] >= '{date_str}T00:00:00Z'")
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
wiql_parts.append(f" AND [System.CreatedDate] < '{_next_day(date_str)}T00:00:00Z'")
|
|
122
|
+
elif date_to:
|
|
123
|
+
wiql_parts.append(f" AND [System.CreatedDate] < '{_next_day(date_to)}T00:00:00Z'")
|
|
110
124
|
wiql_parts.append("ORDER BY [System.CreatedDate] DESC")
|
|
111
125
|
wiql = " ".join(wiql_parts)
|
|
112
126
|
|
|
113
|
-
|
|
127
|
+
# Re-encode path segments — project names commonly contain spaces.
|
|
128
|
+
org_q = urllib.parse.quote(org, safe="")
|
|
129
|
+
project_q = urllib.parse.quote(project, safe="")
|
|
130
|
+
wiql_url = f"https://{host}/{org_q}/{project_q}/_apis/wit/wiql?api-version={API_VERSION}"
|
|
114
131
|
wiql_req = urllib.request.Request(
|
|
115
132
|
wiql_url,
|
|
116
133
|
data=json.dumps({"query": wiql}).encode(),
|
|
@@ -156,7 +173,7 @@ class AzureDevOpsProvider(Provider):
|
|
|
156
173
|
chunk = ids[i:i + MAX_IDS_PER_REQUEST]
|
|
157
174
|
ids_str = ",".join(str(x) for x in chunk)
|
|
158
175
|
details_url = (
|
|
159
|
-
f"https://{host}/{
|
|
176
|
+
f"https://{host}/{org_q}/_apis/wit/workitems"
|
|
160
177
|
f"?ids={ids_str}&$expand=none&api-version={API_VERSION}"
|
|
161
178
|
)
|
|
162
179
|
req = urllib.request.Request(details_url, headers=headers)
|
|
@@ -164,8 +181,20 @@ class AzureDevOpsProvider(Provider):
|
|
|
164
181
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
165
182
|
details = json.loads(resp.read().decode())
|
|
166
183
|
except urllib.error.HTTPError as e:
|
|
167
|
-
|
|
168
|
-
|
|
184
|
+
if e.code in (401, 403):
|
|
185
|
+
raise ProviderAuthError(
|
|
186
|
+
code="token_invalid", status=e.code,
|
|
187
|
+
message="Azure DevOps PAT rejected while fetching work item details.",
|
|
188
|
+
)
|
|
189
|
+
raise ProviderError(
|
|
190
|
+
code=f"http_{e.code}", status=e.code,
|
|
191
|
+
message=f"Azure DevOps work-item fetch returned {e.code} {e.reason}",
|
|
192
|
+
)
|
|
193
|
+
except urllib.error.URLError as e:
|
|
194
|
+
raise ProviderNetworkError(
|
|
195
|
+
code="network_error",
|
|
196
|
+
message=f"Couldn't reach https://{host} — {e.reason}",
|
|
197
|
+
)
|
|
169
198
|
for wi in details.get("value", []):
|
|
170
199
|
results.append(self._normalize(wi, host, org, project))
|
|
171
200
|
|
|
@@ -197,11 +226,13 @@ class AzureDevOpsProvider(Provider):
|
|
|
197
226
|
tags.append(priority_label)
|
|
198
227
|
|
|
199
228
|
created_by = fields.get("System.CreatedBy") or {}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
229
|
+
if isinstance(created_by, str):
|
|
230
|
+
# Older api-versions return a plain "Name <email>" string.
|
|
231
|
+
author_name = created_by
|
|
232
|
+
elif isinstance(created_by, dict):
|
|
233
|
+
author_name = created_by.get("displayName") or created_by.get("uniqueName") or ""
|
|
234
|
+
else:
|
|
235
|
+
author_name = ""
|
|
205
236
|
|
|
206
237
|
return {
|
|
207
238
|
"iid": wi_id,
|
|
@@ -79,11 +79,26 @@ class Provider(ABC):
|
|
|
79
79
|
|
|
80
80
|
@classmethod
|
|
81
81
|
def matches_url(cls, url: str) -> bool:
|
|
82
|
-
"""Heuristic: does this provider own this URL?
|
|
82
|
+
"""Heuristic: does this provider own this URL? Matches host part only,
|
|
83
|
+
so a repo path like github.com/acme/gitlab.integration can't misroute."""
|
|
83
84
|
if not url:
|
|
84
85
|
return False
|
|
85
|
-
lower = url.lower()
|
|
86
|
-
|
|
86
|
+
lower = url.lower().strip()
|
|
87
|
+
# Extract host from URL / ssh / scheme-less forms
|
|
88
|
+
host = lower
|
|
89
|
+
if "://" in host:
|
|
90
|
+
host = host.split("://", 1)[1]
|
|
91
|
+
if host.startswith("git@"):
|
|
92
|
+
host = host[4:].split(":", 1)[0]
|
|
93
|
+
host = host.split("/", 1)[0].split("@")[-1].split(":", 1)[0]
|
|
94
|
+
for p in cls.host_patterns:
|
|
95
|
+
if p.endswith("."):
|
|
96
|
+
# Prefix pattern, e.g. "gitlab." matches gitlab.com / gitlab.example.com
|
|
97
|
+
if host.startswith(p):
|
|
98
|
+
return True
|
|
99
|
+
elif host == p or host.endswith("." + p):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
87
102
|
|
|
88
103
|
@abstractmethod
|
|
89
104
|
def parse_url(self, raw: str) -> tuple:
|