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.
Files changed (47) hide show
  1. {fixfleet-0.4.0/fixfleet.egg-info → fixfleet-0.4.2}/PKG-INFO +14 -3
  2. {fixfleet-0.4.0 → fixfleet-0.4.2}/README.md +13 -2
  3. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/__init__.py +1 -1
  4. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/_subprocess.py +38 -6
  5. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/api/openai_compat.py +33 -16
  6. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/claude.py +3 -0
  7. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/codex.py +3 -0
  8. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/budget.py +10 -0
  9. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/cli.py +15 -1
  10. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/config.py +3 -0
  11. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/json_api.py +56 -5
  12. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/azure.py +42 -11
  13. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/base.py +18 -3
  14. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/bitbucket.py +19 -4
  15. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/github.py +25 -14
  16. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/gitlab.py +24 -2
  17. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/jira.py +30 -12
  18. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/linear.py +11 -6
  19. fixfleet-0.4.2/bugfixer/telemetry.py +152 -0
  20. {fixfleet-0.4.0 → fixfleet-0.4.2/fixfleet.egg-info}/PKG-INFO +14 -3
  21. {fixfleet-0.4.0 → fixfleet-0.4.2}/fixfleet.egg-info/SOURCES.txt +1 -0
  22. {fixfleet-0.4.0 → fixfleet-0.4.2}/pyproject.toml +1 -1
  23. {fixfleet-0.4.0 → fixfleet-0.4.2}/tests/test_all.py +3 -0
  24. {fixfleet-0.4.0 → fixfleet-0.4.2}/tests/test_providers_qa.py +66 -2
  25. {fixfleet-0.4.0 → fixfleet-0.4.2}/LICENSE +0 -0
  26. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/__init__.py +0 -0
  27. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/api/__init__.py +0 -0
  28. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/base.py +0 -0
  29. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/__init__.py +0 -0
  30. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/aider.py +0 -0
  31. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/cursor.py +0 -0
  32. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/gemini.py +0 -0
  33. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/cli/qwen.py +0 -0
  34. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/backends/registry.py +0 -0
  35. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/confidence.py +0 -0
  36. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/gitlab.py +0 -0
  37. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/locator.py +0 -0
  38. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/parser.py +0 -0
  39. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/prompt.py +0 -0
  40. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/__init__.py +0 -0
  41. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/providers/registry.py +0 -0
  42. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/state.py +0 -0
  43. {fixfleet-0.4.0 → fixfleet-0.4.2}/bugfixer/ui.py +0 -0
  44. {fixfleet-0.4.0 → fixfleet-0.4.2}/fixfleet.egg-info/dependency_links.txt +0 -0
  45. {fixfleet-0.4.0 → fixfleet-0.4.2}/fixfleet.egg-info/entry_points.txt +0 -0
  46. {fixfleet-0.4.0 → fixfleet-0.4.2}/fixfleet.egg-info/top_level.txt +0 -0
  47. {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.0
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,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.2"
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 = []
@@ -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)
@@ -26,6 +26,9 @@ DEFAULT_CONFIG = {
26
26
  "inline_max_lines": 250,
27
27
  },
28
28
  "skip_already_fixed": True,
29
+ "telemetry": {
30
+ "enabled": True, # anonymous usage events only; see bugfixer/telemetry.py
31
+ },
29
32
  }
30
33
 
31
34
 
@@ -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]] = value
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="gitlab",
380
- help="Issue-tracker provider: gitlab (default), github, jira, linear")
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
- 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: