fixfleet 0.4.0__tar.gz → 0.4.1__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-0.4.1}/PKG-INFO +3 -3
- {fixfleet-0.4.0 → fixfleet-0.4.1}/README.md +2 -2
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/__init__.py +1 -1
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/_subprocess.py +38 -6
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/api/openai_compat.py +33 -16
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/claude.py +3 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/codex.py +3 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/budget.py +10 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/json_api.py +28 -4
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/azure.py +42 -11
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/base.py +18 -3
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/bitbucket.py +19 -4
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/github.py +25 -14
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/gitlab.py +24 -2
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/jira.py +30 -12
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/linear.py +11 -6
- {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/PKG-INFO +3 -3
- {fixfleet-0.4.0 → fixfleet-0.4.1}/pyproject.toml +1 -1
- {fixfleet-0.4.0 → fixfleet-0.4.1}/tests/test_providers_qa.py +61 -2
- {fixfleet-0.4.0 → fixfleet-0.4.1}/LICENSE +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/__init__.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/api/__init__.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/base.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/__init__.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/aider.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/cursor.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/gemini.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/qwen.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/registry.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/cli.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/confidence.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/config.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/gitlab.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/locator.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/parser.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/prompt.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/__init__.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/registry.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/state.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/ui.py +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/SOURCES.txt +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/dependency_links.txt +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/entry_points.txt +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/top_level.txt +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/setup.cfg +0 -0
- {fixfleet-0.4.0 → fixfleet-0.4.1}/tests/test_all.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixfleet
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -409,8 +409,8 @@ No. FixFleet runs **100% locally** on your machine. No FixFleet servers, no Azur
|
|
|
409
409
|
<summary><b>What's the difference between the VSCode extension and the CLI?</b></summary>
|
|
410
410
|
|
|
411
411
|
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
|
|
412
|
+
- **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.
|
|
413
|
+
- **VSCode extension** — premium UI sidebar + click-to-fix workflow for all 6 trackers, calls the CLI under the hood
|
|
414
414
|
|
|
415
415
|
Install both if you want flexibility. Extension auto-installs the CLI on first run if missing.
|
|
416
416
|
|
|
@@ -381,8 +381,8 @@ No. FixFleet runs **100% locally** on your machine. No FixFleet servers, no Azur
|
|
|
381
381
|
<summary><b>What's the difference between the VSCode extension and the CLI?</b></summary>
|
|
382
382
|
|
|
383
383
|
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
|
|
384
|
+
- **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.
|
|
385
|
+
- **VSCode extension** — premium UI sidebar + click-to-fix workflow for all 6 trackers, calls the CLI under the hood
|
|
386
386
|
|
|
387
387
|
Install both if you want flexibility. Extension auto-installs the CLI on first run if missing.
|
|
388
388
|
|
|
@@ -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 = []
|
|
@@ -252,12 +252,27 @@ def cmd_fix_issue(args):
|
|
|
252
252
|
loc = locate(parsed, project_dir)
|
|
253
253
|
prompt = build_prompt(parsed, locator=loc)
|
|
254
254
|
|
|
255
|
-
# Budget check
|
|
255
|
+
# Budget check — enforced unless --force is passed.
|
|
256
|
+
cfg_budgets = (config.load().get("budgets") or {})
|
|
256
257
|
daily_used = state.get_daily_usage(backend.name)
|
|
257
258
|
check = budget.check_budget(
|
|
258
259
|
prompt, backend.name,
|
|
259
260
|
session_used=0, daily_used=daily_used,
|
|
261
|
+
budgets=cfg_budgets,
|
|
260
262
|
)
|
|
263
|
+
if not check.allowed and not getattr(args, "force", False):
|
|
264
|
+
_emit({
|
|
265
|
+
"ok": False,
|
|
266
|
+
"code": "budget_exceeded",
|
|
267
|
+
"error": f"Budget would be exceeded: {check.reason}. Pass --force to run anyway.",
|
|
268
|
+
"budget": {
|
|
269
|
+
"estimated": check.estimated,
|
|
270
|
+
"per_issue_max": check.per_issue_max,
|
|
271
|
+
"daily_used": check.daily_used,
|
|
272
|
+
"daily_max": check.daily_max,
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
sys.exit(1)
|
|
261
276
|
|
|
262
277
|
# Run backend
|
|
263
278
|
result: RunResult = backend.run(prompt, project_dir)
|
|
@@ -341,12 +356,18 @@ def cmd_config_set(args):
|
|
|
341
356
|
if "=" not in pair:
|
|
342
357
|
_err(f"invalid --config-set entry: {pair}")
|
|
343
358
|
key, value = pair.split("=", 1)
|
|
359
|
+
# Coerce to native JSON types (int, float, bool, null) so numeric
|
|
360
|
+
# settings like budgets don't get stored as strings.
|
|
361
|
+
try:
|
|
362
|
+
parsed_value = json.loads(value)
|
|
363
|
+
except (json.JSONDecodeError, ValueError):
|
|
364
|
+
parsed_value = value
|
|
344
365
|
# Support nested keys via dot notation: api.base_url=...
|
|
345
366
|
parts = key.split(".")
|
|
346
367
|
target = cfg
|
|
347
368
|
for p in parts[:-1]:
|
|
348
369
|
target = target.setdefault(p, {})
|
|
349
|
-
target[parts[-1]] =
|
|
370
|
+
target[parts[-1]] = parsed_value
|
|
350
371
|
|
|
351
372
|
config.save(cfg)
|
|
352
373
|
_emit({"ok": True, "saved": list(args.config_set)})
|
|
@@ -376,9 +397,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
376
397
|
p.add_argument("--token", help="Issue-tracker token (or set BUGFIXER_TOKEN env var)")
|
|
377
398
|
p.add_argument("--project-url", help="Project URL or ID")
|
|
378
399
|
p.add_argument("--project-dir", help="Local project directory")
|
|
379
|
-
p.add_argument("--provider", default=
|
|
380
|
-
help="Issue-tracker provider: gitlab
|
|
400
|
+
p.add_argument("--provider", default=None,
|
|
401
|
+
help="Issue-tracker provider: gitlab, github, jira, linear, bitbucket, azure "
|
|
402
|
+
"(default: auto-detect from --project-url, falling back to gitlab)")
|
|
381
403
|
p.add_argument("--backend", help="AI backend name: claude, codex, gemini, cursor, aider, qwen, openai_compat")
|
|
404
|
+
p.add_argument("--force", action="store_true",
|
|
405
|
+
help="Run --fix-issue even when the token budget would be exceeded")
|
|
382
406
|
p.add_argument("--date", help="Filter bugs created on YYYY-MM-DD (single day)")
|
|
383
407
|
p.add_argument("--date-from", dest="date_from", help="Filter bugs created on/after YYYY-MM-DD")
|
|
384
408
|
p.add_argument("--date-to", dest="date_to", help="Filter bugs created on/before YYYY-MM-DD")
|
|
@@ -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:
|
|
@@ -11,6 +11,7 @@ import json
|
|
|
11
11
|
import urllib.error
|
|
12
12
|
import urllib.parse
|
|
13
13
|
import urllib.request
|
|
14
|
+
from datetime import datetime, timedelta
|
|
14
15
|
from typing import Optional
|
|
15
16
|
|
|
16
17
|
from .base import (
|
|
@@ -28,6 +29,15 @@ PER_PAGE = 50
|
|
|
28
29
|
MAX_PAGES = 20
|
|
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 BitbucketProvider(Provider):
|
|
32
42
|
key = "bitbucket"
|
|
33
43
|
display_name = "Bitbucket"
|
|
@@ -81,14 +91,19 @@ class BitbucketProvider(Provider):
|
|
|
81
91
|
auth = base64.b64encode(token.encode()).decode()
|
|
82
92
|
base_url = f"https://{API_HOST}/2.0/repositories/{project_id}/issues"
|
|
83
93
|
|
|
84
|
-
# Build BBQL query
|
|
85
|
-
|
|
94
|
+
# Build BBQL query. Bitbucket creates issues in state "new" — include it,
|
|
95
|
+
# or freshly reported bugs never show up.
|
|
96
|
+
query_parts = ['kind="bug"', '(state="new" OR state="open" OR state="on hold")']
|
|
86
97
|
if date_from:
|
|
87
98
|
query_parts.append(f'created_on >= {date_from}T00:00:00Z')
|
|
99
|
+
if date_to:
|
|
100
|
+
query_parts.append(f'created_on < {_next_day(date_to)}T00:00:00Z')
|
|
88
101
|
elif date_str:
|
|
102
|
+
# Single-day window, matching GitLab's --date semantics.
|
|
89
103
|
query_parts.append(f'created_on >= {date_str}T00:00:00Z')
|
|
90
|
-
|
|
91
|
-
|
|
104
|
+
query_parts.append(f'created_on < {_next_day(date_str)}T00:00:00Z')
|
|
105
|
+
elif date_to:
|
|
106
|
+
query_parts.append(f'created_on < {_next_day(date_to)}T00:00:00Z')
|
|
92
107
|
|
|
93
108
|
params = {
|
|
94
109
|
"q": " AND ".join(query_parts),
|
|
@@ -92,11 +92,13 @@ class GitHubProvider(Provider):
|
|
|
92
92
|
"sort": "created",
|
|
93
93
|
"direction": "desc",
|
|
94
94
|
}
|
|
95
|
-
# GitHub
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
# GitHub's `since` filters by updated_at, not created_at. Since
|
|
96
|
+
# updated_at >= created_at, it's a safe server-side pre-filter; the
|
|
97
|
+
# exact created_at window is enforced client-side below.
|
|
98
|
+
created_lower = date_from or date_str or ""
|
|
99
|
+
created_upper = date_to or (date_str if date_str and not date_from else "")
|
|
100
|
+
if created_lower:
|
|
101
|
+
params["since"] = f"{created_lower}T00:00:00Z"
|
|
100
102
|
|
|
101
103
|
results = []
|
|
102
104
|
page = 1
|
|
@@ -123,11 +125,16 @@ class GitHubProvider(Provider):
|
|
|
123
125
|
code="token_invalid", status=401,
|
|
124
126
|
message="GitHub token is invalid or expired. Generate a new one with scope `repo` or `public_repo`.",
|
|
125
127
|
)
|
|
126
|
-
if e.code
|
|
127
|
-
|
|
128
|
+
if e.code in (403, 429):
|
|
129
|
+
remaining = (e.headers or {}).get("x-ratelimit-remaining", "")
|
|
130
|
+
if e.code == 429 or remaining == "0":
|
|
131
|
+
raise ProviderError(
|
|
132
|
+
code="rate_limited", status=e.code,
|
|
133
|
+
message="GitHub rate limit exceeded. Wait a few minutes and retry.",
|
|
134
|
+
)
|
|
128
135
|
raise ProviderAuthError(
|
|
129
136
|
code="token_forbidden", status=403,
|
|
130
|
-
message="GitHub token lacks required scope
|
|
137
|
+
message="GitHub token lacks required scope. Need `repo` (or `public_repo`).",
|
|
131
138
|
)
|
|
132
139
|
if e.code == 404:
|
|
133
140
|
raise ProviderNotFoundError(
|
|
@@ -148,18 +155,22 @@ class GitHubProvider(Provider):
|
|
|
148
155
|
break
|
|
149
156
|
|
|
150
157
|
# Filter out pull requests (GitHub's /issues endpoint returns both)
|
|
158
|
+
# and enforce the created_at window client-side.
|
|
159
|
+
below_lower = False
|
|
151
160
|
for raw_issue in batch:
|
|
152
161
|
if raw_issue.get("pull_request"):
|
|
153
162
|
continue
|
|
154
|
-
|
|
155
|
-
if
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
163
|
+
created = raw_issue.get("created_at", "")[:10]
|
|
164
|
+
if created_upper and created > created_upper:
|
|
165
|
+
continue
|
|
166
|
+
if created_lower and created < created_lower:
|
|
167
|
+
# Sorted by created desc — everything after this is older.
|
|
168
|
+
below_lower = True
|
|
169
|
+
break
|
|
159
170
|
results.append(self._normalize(raw_issue, host, project_id))
|
|
160
171
|
|
|
161
172
|
# Pagination via Link header
|
|
162
|
-
if 'rel="next"' not in link_header:
|
|
173
|
+
if below_lower or 'rel="next"' not in link_header:
|
|
163
174
|
break
|
|
164
175
|
page += 1
|
|
165
176
|
|
|
@@ -70,8 +70,18 @@ class GitLabProvider(Provider):
|
|
|
70
70
|
|
|
71
71
|
# Plain path
|
|
72
72
|
if "/" in s and not s.startswith(("/", ".", "~")):
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
# Scheme-less URL with a host prefix, e.g. gitlab.example.com/grp/proj
|
|
74
|
+
first = s.split("/", 1)[0]
|
|
75
|
+
if "." in first and not first.isdigit():
|
|
76
|
+
host = first
|
|
77
|
+
path = s.split("/", 1)[1].rstrip("/")
|
|
78
|
+
if path.endswith(".git"):
|
|
79
|
+
path = path[:-4]
|
|
80
|
+
if "/-/" in path:
|
|
81
|
+
path = path.split("/-/")[0]
|
|
82
|
+
if not path:
|
|
83
|
+
raise ValueError(f"could not parse URL: {raw}")
|
|
84
|
+
return host, path
|
|
75
85
|
return DEFAULT_HOST, s.rstrip("/")
|
|
76
86
|
|
|
77
87
|
raise ValueError(f"unrecognized project reference: {raw}")
|
|
@@ -110,6 +120,18 @@ class GitLabProvider(Provider):
|
|
|
110
120
|
params["created_after"] = f"{date_str}T00:00:00Z"
|
|
111
121
|
params["created_before"] = f"{end_date}T00:00:00Z"
|
|
112
122
|
|
|
123
|
+
all_issues = self._fetch_pages(token, base_url, params, host, project_id)
|
|
124
|
+
|
|
125
|
+
# GitLab label matching is exact/case-sensitive. If "Bug" found nothing,
|
|
126
|
+
# retry with lowercase "bug" so projects using that convention still work.
|
|
127
|
+
if not all_issues:
|
|
128
|
+
params = {**params, "labels": "bug"}
|
|
129
|
+
all_issues = self._fetch_pages(token, base_url, params, host, project_id)
|
|
130
|
+
|
|
131
|
+
return all_issues
|
|
132
|
+
|
|
133
|
+
def _fetch_pages(self, token: str, base_url: str, params: dict,
|
|
134
|
+
host: str, project_id: str) -> list:
|
|
113
135
|
all_issues = []
|
|
114
136
|
page = 1
|
|
115
137
|
while page <= MAX_PAGES:
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Jira provider — Atlassian Cloud REST API v3 + JQL.
|
|
2
2
|
|
|
3
|
-
Endpoint: https://{your-domain}.atlassian.net/rest/api/3/search
|
|
3
|
+
Endpoint: https://{your-domain}.atlassian.net/rest/api/3/search/jql
|
|
4
|
+
(the old /rest/api/3/search was removed by Atlassian in 2025 and returns 410;
|
|
5
|
+
the replacement paginates with a nextPageToken cursor instead of startAt/total)
|
|
4
6
|
Auth: Basic (email:api-token, base64-encoded)
|
|
5
7
|
|
|
6
8
|
User must paste token as: 'email@example.com:atlassian-api-token'
|
|
@@ -11,6 +13,7 @@ import json
|
|
|
11
13
|
import urllib.error
|
|
12
14
|
import urllib.parse
|
|
13
15
|
import urllib.request
|
|
16
|
+
from datetime import datetime, timedelta
|
|
14
17
|
from typing import Optional
|
|
15
18
|
|
|
16
19
|
from .base import (
|
|
@@ -26,6 +29,15 @@ PER_PAGE = 50
|
|
|
26
29
|
MAX_PAGES = 20
|
|
27
30
|
|
|
28
31
|
|
|
32
|
+
def _next_day(date_str: str) -> str:
|
|
33
|
+
"""YYYY-MM-DD → next day, for exclusive upper bounds in JQL."""
|
|
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
|
+
|
|
29
41
|
class JiraProvider(Provider):
|
|
30
42
|
key = "jira"
|
|
31
43
|
display_name = "Jira"
|
|
@@ -93,28 +105,35 @@ class JiraProvider(Provider):
|
|
|
93
105
|
)
|
|
94
106
|
|
|
95
107
|
auth = base64.b64encode(token.encode()).decode()
|
|
96
|
-
base_url = f"https://{host}/rest/api/3/search"
|
|
108
|
+
base_url = f"https://{host}/rest/api/3/search/jql"
|
|
97
109
|
|
|
98
|
-
# Build JQL
|
|
99
|
-
|
|
110
|
+
# Build JQL. Escape quotes/backslashes — project_id can come from stored config.
|
|
111
|
+
proj = project_id.replace("\\", "\\\\").replace('"', '\\"')
|
|
112
|
+
jql_parts = [f'project = "{proj}"', 'issuetype = "Bug"', 'statusCategory != Done']
|
|
100
113
|
if date_from:
|
|
101
114
|
jql_parts.append(f'created >= "{date_from}"')
|
|
115
|
+
if date_to:
|
|
116
|
+
# Jira parses a bare date as 00:00 — bound with next day to include date_to fully.
|
|
117
|
+
jql_parts.append(f'created < "{_next_day(date_to)}"')
|
|
102
118
|
elif date_str:
|
|
119
|
+
# Single-day window, matching GitLab's --date semantics.
|
|
103
120
|
jql_parts.append(f'created >= "{date_str}"')
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
jql_parts.append(f'created < "{_next_day(date_str)}"')
|
|
122
|
+
elif date_to:
|
|
123
|
+
jql_parts.append(f'created < "{_next_day(date_to)}"')
|
|
106
124
|
jql = " AND ".join(jql_parts) + " ORDER BY created DESC"
|
|
107
125
|
|
|
108
126
|
results = []
|
|
109
|
-
|
|
127
|
+
next_page_token = ""
|
|
110
128
|
page = 0
|
|
111
129
|
while page < MAX_PAGES:
|
|
112
130
|
params = {
|
|
113
131
|
"jql": jql,
|
|
114
|
-
"startAt": str(start_at),
|
|
115
132
|
"maxResults": str(PER_PAGE),
|
|
116
133
|
"fields": "summary,description,labels,priority,created,updated,creator,reporter,issuetype,status",
|
|
117
134
|
}
|
|
135
|
+
if next_page_token:
|
|
136
|
+
params["nextPageToken"] = next_page_token
|
|
118
137
|
url = f"{base_url}?{urllib.parse.urlencode(params)}"
|
|
119
138
|
req = urllib.request.Request(
|
|
120
139
|
url,
|
|
@@ -154,13 +173,12 @@ class JiraProvider(Provider):
|
|
|
154
173
|
message=f"Couldn't reach https://{host} — {e.reason}",
|
|
155
174
|
)
|
|
156
175
|
|
|
157
|
-
issues_batch = data.get("issues"
|
|
176
|
+
issues_batch = data.get("issues") or []
|
|
158
177
|
for raw_issue in issues_batch:
|
|
159
178
|
results.append(self._normalize(raw_issue, host, project_id))
|
|
160
179
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if start_at >= total or not issues_batch:
|
|
180
|
+
next_page_token = data.get("nextPageToken") or ""
|
|
181
|
+
if not next_page_token or not issues_batch:
|
|
164
182
|
break
|
|
165
183
|
page += 1
|
|
166
184
|
|
|
@@ -37,7 +37,7 @@ query Issues($teamKey: String!, $cursor: String, $first: Int!, $createdAfter: Da
|
|
|
37
37
|
first: $first,
|
|
38
38
|
after: $cursor,
|
|
39
39
|
filter: {
|
|
40
|
-
state: { type: { in: ["unstarted", "started", "backlog"] } },
|
|
40
|
+
state: { type: { in: ["triage", "unstarted", "started", "backlog"] } },
|
|
41
41
|
labels: { name: { eqIgnoreCase: "Bug" } },
|
|
42
42
|
createdAt: { gte: $createdAfter, lte: $createdBefore }
|
|
43
43
|
}
|
|
@@ -121,11 +121,16 @@ class LinearProvider(Provider):
|
|
|
121
121
|
) -> list:
|
|
122
122
|
# Resolve team UUID from team key (Linear's id can be UUID or key in newer API).
|
|
123
123
|
# We pass the key; Linear accepts both.
|
|
124
|
-
|
|
125
|
-
f"{date_from}T00:00:00Z"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
if date_from:
|
|
125
|
+
date_after = f"{date_from}T00:00:00Z"
|
|
126
|
+
date_before = f"{date_to}T23:59:59Z" if date_to else None
|
|
127
|
+
elif date_str:
|
|
128
|
+
# Single-day window, matching GitLab's --date semantics.
|
|
129
|
+
date_after = f"{date_str}T00:00:00Z"
|
|
130
|
+
date_before = f"{date_str}T23:59:59Z"
|
|
131
|
+
else:
|
|
132
|
+
date_after = None
|
|
133
|
+
date_before = f"{date_to}T23:59:59Z" if date_to else None
|
|
129
134
|
|
|
130
135
|
results = []
|
|
131
136
|
cursor = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixfleet
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -409,8 +409,8 @@ No. FixFleet runs **100% locally** on your machine. No FixFleet servers, no Azur
|
|
|
409
409
|
<summary><b>What's the difference between the VSCode extension and the CLI?</b></summary>
|
|
410
410
|
|
|
411
411
|
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
|
|
412
|
+
- **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.
|
|
413
|
+
- **VSCode extension** — premium UI sidebar + click-to-fix workflow for all 6 trackers, calls the CLI under the hood
|
|
414
414
|
|
|
415
415
|
Install both if you want flexibility. Extension auto-installs the CLI on first run if missing.
|
|
416
416
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fixfleet"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "Auto-fix bugs from GitHub, GitLab, Jira, Linear, Bitbucket, or Azure DevOps with AI agents — bring your own CLI or free API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -30,7 +30,7 @@ from bugfixer.providers import (
|
|
|
30
30
|
class FakeResponse:
|
|
31
31
|
"""Mimics urllib's addinfourl response object."""
|
|
32
32
|
|
|
33
|
-
def __init__(self, body
|
|
33
|
+
def __init__(self, body, headers: dict = None):
|
|
34
34
|
self._body = json.dumps(body).encode()
|
|
35
35
|
self.headers = _HeadersDict(headers or {})
|
|
36
36
|
|
|
@@ -199,6 +199,19 @@ class TestBitbucketQA(unittest.TestCase):
|
|
|
199
199
|
with self.assertRaises(ProviderAuthError):
|
|
200
200
|
self.p.fetch_bugs("u:bad", "team/repo")
|
|
201
201
|
|
|
202
|
+
def test_query_includes_new_state(self):
|
|
203
|
+
"""Bitbucket creates issues in state 'new' — the filter must include it."""
|
|
204
|
+
captured = {}
|
|
205
|
+
def capture(req, timeout=0):
|
|
206
|
+
captured["url"] = req.full_url
|
|
207
|
+
return FakeResponse({"values": [], "next": None})
|
|
208
|
+
with patch("urllib.request.urlopen", side_effect=capture):
|
|
209
|
+
self.p.fetch_bugs("u:p", "team/repo")
|
|
210
|
+
from urllib.parse import unquote_plus
|
|
211
|
+
q = unquote_plus(captured["url"])
|
|
212
|
+
self.assertIn('state="new"', q)
|
|
213
|
+
self.assertIn('state="open"', q)
|
|
214
|
+
|
|
202
215
|
|
|
203
216
|
# ══════════════════════════════════════════════════════════════
|
|
204
217
|
# Linear
|
|
@@ -353,11 +366,14 @@ class TestJiraQA(unittest.TestCase):
|
|
|
353
366
|
self.p.fetch_bugs("me@x.com:tok", "MYPROJ", host="acme.atlassian.net",
|
|
354
367
|
date_from="2026-06-01", date_to="2026-06-04")
|
|
355
368
|
from urllib.parse import unquote_plus
|
|
369
|
+
# New cursor-paginated endpoint (old /rest/api/3/search returns 410)
|
|
370
|
+
self.assertIn("/rest/api/3/search/jql", captured["url"])
|
|
356
371
|
jql = unquote_plus(captured["url"].split("jql=")[1].split("&")[0])
|
|
357
372
|
self.assertIn('project = "MYPROJ"', jql)
|
|
358
373
|
self.assertIn('issuetype = "Bug"', jql)
|
|
359
374
|
self.assertIn('created >= "2026-06-01"', jql)
|
|
360
|
-
|
|
375
|
+
# date_to is inclusive — bounded by an exclusive next-day upper bound
|
|
376
|
+
self.assertIn('created < "2026-06-05"', jql)
|
|
361
377
|
|
|
362
378
|
def test_401(self):
|
|
363
379
|
with patch("urllib.request.urlopen", side_effect=http_error(401)):
|
|
@@ -574,5 +590,48 @@ class TestJsonApiWithProviders(unittest.TestCase):
|
|
|
574
590
|
self.assertTrue(result.get("ok"))
|
|
575
591
|
|
|
576
592
|
|
|
593
|
+
class TestProviderDetection(unittest.TestCase):
|
|
594
|
+
"""URL → provider routing must match on host, never on path substrings."""
|
|
595
|
+
|
|
596
|
+
def test_github_url_with_gitlab_in_path(self):
|
|
597
|
+
from bugfixer.providers import detect_provider_from_url
|
|
598
|
+
p = detect_provider_from_url("https://github.com/acme/gitlab.integration")
|
|
599
|
+
self.assertIsNotNone(p)
|
|
600
|
+
self.assertEqual(p.key, "github")
|
|
601
|
+
|
|
602
|
+
def test_self_hosted_gitlab(self):
|
|
603
|
+
from bugfixer.providers import detect_provider_from_url
|
|
604
|
+
p = detect_provider_from_url("https://gitlab.mycompany.com/grp/proj")
|
|
605
|
+
self.assertIsNotNone(p)
|
|
606
|
+
self.assertEqual(p.key, "gitlab")
|
|
607
|
+
|
|
608
|
+
def test_ssh_github(self):
|
|
609
|
+
from bugfixer.providers import detect_provider_from_url
|
|
610
|
+
p = detect_provider_from_url("git@github.com:owner/repo.git")
|
|
611
|
+
self.assertIsNotNone(p)
|
|
612
|
+
self.assertEqual(p.key, "github")
|
|
613
|
+
|
|
614
|
+
def test_jira_cloud(self):
|
|
615
|
+
from bugfixer.providers import detect_provider_from_url
|
|
616
|
+
p = detect_provider_from_url("https://acme.atlassian.net/browse/MYPROJ-42")
|
|
617
|
+
self.assertIsNotNone(p)
|
|
618
|
+
self.assertEqual(p.key, "jira")
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
class TestBudgetCoercion(unittest.TestCase):
|
|
622
|
+
"""Budgets stored as strings (legacy --config-set) must not crash."""
|
|
623
|
+
|
|
624
|
+
def test_string_budgets_coerced(self):
|
|
625
|
+
from bugfixer.budget import check_budget
|
|
626
|
+
check = check_budget(
|
|
627
|
+
"x" * 1000, "claude", session_used=0, daily_used=0,
|
|
628
|
+
budgets={"per_issue_max_tokens": "50000",
|
|
629
|
+
"session_max_tokens": "200000",
|
|
630
|
+
"daily_max_tokens": "500000"},
|
|
631
|
+
)
|
|
632
|
+
self.assertTrue(check.allowed)
|
|
633
|
+
self.assertEqual(check.per_issue_max, 50000)
|
|
634
|
+
|
|
635
|
+
|
|
577
636
|
if __name__ == "__main__":
|
|
578
637
|
unittest.main(verbosity=2)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|