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.
Files changed (46) hide show
  1. {fixfleet-0.4.0 → fixfleet-0.4.1}/PKG-INFO +3 -3
  2. {fixfleet-0.4.0 → fixfleet-0.4.1}/README.md +2 -2
  3. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/__init__.py +1 -1
  4. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/_subprocess.py +38 -6
  5. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/api/openai_compat.py +33 -16
  6. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/claude.py +3 -0
  7. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/codex.py +3 -0
  8. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/budget.py +10 -0
  9. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/json_api.py +28 -4
  10. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/azure.py +42 -11
  11. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/base.py +18 -3
  12. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/bitbucket.py +19 -4
  13. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/github.py +25 -14
  14. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/gitlab.py +24 -2
  15. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/jira.py +30 -12
  16. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/linear.py +11 -6
  17. {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/PKG-INFO +3 -3
  18. {fixfleet-0.4.0 → fixfleet-0.4.1}/pyproject.toml +1 -1
  19. {fixfleet-0.4.0 → fixfleet-0.4.1}/tests/test_providers_qa.py +61 -2
  20. {fixfleet-0.4.0 → fixfleet-0.4.1}/LICENSE +0 -0
  21. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/__init__.py +0 -0
  22. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/api/__init__.py +0 -0
  23. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/base.py +0 -0
  24. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/__init__.py +0 -0
  25. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/aider.py +0 -0
  26. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/cursor.py +0 -0
  27. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/gemini.py +0 -0
  28. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/cli/qwen.py +0 -0
  29. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/backends/registry.py +0 -0
  30. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/cli.py +0 -0
  31. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/confidence.py +0 -0
  32. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/config.py +0 -0
  33. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/gitlab.py +0 -0
  34. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/locator.py +0 -0
  35. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/parser.py +0 -0
  36. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/prompt.py +0 -0
  37. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/__init__.py +0 -0
  38. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/providers/registry.py +0 -0
  39. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/state.py +0 -0
  40. {fixfleet-0.4.0 → fixfleet-0.4.1}/bugfixer/ui.py +0 -0
  41. {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/SOURCES.txt +0 -0
  42. {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/dependency_links.txt +0 -0
  43. {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/entry_points.txt +0 -0
  44. {fixfleet-0.4.0 → fixfleet-0.4.1}/fixfleet.egg-info/top_level.txt +0 -0
  45. {fixfleet-0.4.0 → fixfleet-0.4.1}/setup.cfg +0 -0
  46. {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.0
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,6 @@
1
1
  """FixFleet — Fleet of AI agents fixing bugs across any issue tracker."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.1"
4
4
  __name_pretty__ = "FixFleet"
5
5
  __tagline__ = "Fleet of AI agents fixing bugs across any tracker"
6
6
  __author__ = "Yash Koladiya"
@@ -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
- try:
36
- proc.stdin.write(stdin_data)
37
- proc.stdin.close()
38
- except (BrokenPipeError, OSError):
39
- pass
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.kill()
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
- req = urllib.request.Request(
70
- url,
71
- data=data,
72
- headers={
73
- "Content-Type": "application/json",
74
- "Authorization": f"Bearer {self.api_key}" if self.api_key else "",
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
- if text.lstrip().startswith("---") and "+++" in text:
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]] = value
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="gitlab",
380
- help="Issue-tracker provider: gitlab (default), github, jira, linear")
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
- if date_to:
109
- wiql_parts.append(f" AND [System.CreatedDate] <= '{date_to}T23:59:59Z'")
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
- wiql_url = f"https://{host}/{org}/{project}/_apis/wit/wiql?api-version={API_VERSION}"
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}/{org}/_apis/wit/workitems"
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
- # Skip chunk on error — partial results better than none
168
- continue
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
- author_name = (
201
- created_by.get("displayName")
202
- or created_by.get("uniqueName")
203
- or (created_by if isinstance(created_by, str) else "")
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
- return any(p in lower for p in cls.host_patterns)
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
- query_parts = ['kind="bug"', 'state="open"']
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
- if date_to:
91
- query_parts.append(f'created_on <= {date_to}T23:59:59Z')
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 supports `since=ISO` for updated_at filter we use as best-effort created filter
96
- if date_from:
97
- params["since"] = f"{date_from}T00:00:00Z"
98
- elif date_str:
99
- params["since"] = f"{date_str}T00:00:00Z"
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 == 403:
127
- # Could be rate limit OR scope issue
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 or rate limit exceeded. Need `repo` 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
- # Optional client-side date_to filter (GitHub only supports `since`)
155
- if date_to:
156
- created = raw_issue.get("created_at", "")[:10]
157
- if created > date_to:
158
- continue
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
- if s.startswith("gitlab.com/"):
74
- return DEFAULT_HOST, s[len("gitlab.com/"):].rstrip("/")
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
- jql_parts = [f'project = "{project_id}"', 'issuetype = "Bug"', 'statusCategory != Done']
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
- if date_to:
105
- jql_parts.append(f'created <= "{date_to}"')
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
- start_at = 0
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
- total = data.get("total", 0)
162
- start_at += len(issues_batch)
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
- date_after = (
125
- f"{date_from}T00:00:00Z" if date_from
126
- else (f"{date_str}T00:00:00Z" if date_str else None)
127
- )
128
- date_before = f"{date_to}T23:59:59Z" if date_to else None
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.0
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.0"
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: dict | list, headers: dict = None):
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
- self.assertIn('created <= "2026-06-04"', jql)
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