From 158eba76b42100b56d7b37683250e504ebc160d9 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Thu, 25 Jun 2026 14:12:07 -0400 Subject: [PATCH] fix: Bitbucket Data Center integration hardening (v2.40.0) Fixes seven issues found in a live PR-lifecycle evaluation against a Bitbucket Data Center server, plus a version bump and docs. - get_pr_diff requests Accept: text/plain so it returns a unified diff instead of Bitbucket's JSON diff model (Issue 1) - whoami resolves the user from the X-AUSERNAME response header (DC has no /users/me); the login probe gates on application-properties only so a valid login never prints a false failure (Issue 2) - load_bitbucket_config falls back to ~/.c3/config.json when the project has no active account; add `c3 bitbucket login --global` (Issue 3) - ASCII output + UTF-8 stdout/stderr reconfigure so Bitbucket output no longer mojibakes or crashes on Windows cp1252 consoles (Issue 4) - User-Agent derived from the installed package version, not hardcoded 2.30.0 (Issue 5) - _cap hard-clamps a single over-long line as a final guard (Issue 7) - docs: --global flag, account-resolution precedence, and an upgrade-safety note re: pip ~-prefixed backup dirs (Issue 6) Tests: header-based whoami, text/plain diff negotiation, dynamic User-Agent, project->home config fallback precedence, and the _cap single-line clamp. ruff clean; bitbucket + config + claude_md suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 43 +++++++++++++++ README.md | 17 +++++- cli/c3.py | 47 +++++++++++++---- cli/commands/parser.py | 1 + cli/tools/bitbucket.py | 45 +++++++++------- core/config.py | 42 ++++++++++++--- pyproject.toml | 2 +- services/bitbucket_client.py | 57 ++++++++++++++++---- services/claude_md.py | 2 +- tests/test_bitbucket_client.py | 67 +++++++++++++++++++++-- tests/test_bitbucket_config_fallback.py | 70 +++++++++++++++++++++++++ tests/test_bitbucket_tool.py | 10 ++++ 12 files changed, 345 insertions(+), 58 deletions(-) create mode 100644 tests/test_bitbucket_config_fallback.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b4813..67663f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.40.0] - 2026-06-25 + +A Bitbucket Data Center / Server fix batch. A full PR-lifecycle evaluation against a +live Data Center server surfaced seven issues in the `c3_bitbucket` tool and `c3 bitbucket` +CLI; all are fixed here. + +### Fixed + +- **`get_pr_diff` returned the JSON diff model instead of a unified diff.** `_request` + always sent `Accept: application/json`, so Bitbucket content-negotiated to its + structured diff object even though `get_pr_diff` requested the raw body. `_request` + now takes an `accept` parameter and `get_pr_diff` asks for `text/plain`, yielding a + readable `diff --git … / @@ … @@` unified diff. (Issue 1) +- **`whoami` 404'd on Data Center and made valid logins look failed.** `GET + /users/me` is a Bitbucket **Cloud** convention; DC treats `me` as a literal username. + `whoami` now resolves the account from the `X-AUSERNAME` response header (carried on + every authenticated request) and enriches via `GET /users/{slug}`. The `login` + connection probe gates success on `application-properties` only and treats the + `whoami` enrichment as best-effort, so a valid token never prints a probe failure. + (Issue 2) +- **Bitbucket account was per-project with no global fallback.** A project that had + never run `c3 bitbucket login` reported "no active account" even when the user had + logged in elsewhere. `load_bitbucket_config` now falls back to `~/.c3/config.json` + when the project has no active account (precedence: project → home → defaults), and a + new `c3 bitbucket login --global` writes the home config for reuse everywhere. The + PAT still lives only in the OS keyring. (Issue 3) +- **Unicode output mojibaked / crashed on Windows cp1252 consoles.** Decorative glyphs + (`→ — ✓ · …`) are replaced with ASCII (`-> -- [x] [ ] ...`) in the Bitbucket + formatters and status output, and the `c3` CLI now reconfigures stdout/stderr to + UTF-8 at entry so server-supplied text (PR titles, branch names, diffs) renders + cleanly instead of raising `UnicodeEncodeError`. (Issue 4) +- **Stale `User-Agent`.** `c3-bitbucket/2.30.0` was hardcoded; it is now derived from + the installed package version via `importlib.metadata`. (Issue 5) +- **Response cap could emit one over-long line.** `_cap` reduced output line-by-line + but never split a single line; it now hard-clamps by characters as a final guard. + (Issue 7) + +### Docs + +- Documented the `--global` login flag, the project → home account-resolution + precedence, and an upgrade-safety note (stop the running server before `c3 upgrade` + to avoid pip's `~`-prefixed backup dirs in `site-packages`). (Issue 6) + ## [2.39.1] - 2026-06-24 A small reliability fix for `c3_delegate`. diff --git a/README.md b/README.md index 77ddf13..597959d 100644 --- a/README.md +++ b/README.md @@ -221,9 +221,12 @@ Access Token. Tokens live in the **OS keyring** (Windows Credential Manager, macOS Keychain, Linux Secret Service) — never in `.c3/config.json`. ```bash -# One-time login per server +# One-time login per server (stored under this project's .c3/config.json) c3 bitbucket login --url https://bitbucket.example.com -# → prompts for username + PAT (masked) +# -> prompts for username + PAT (masked) + +# ...or store it globally so every C3 project can use it +c3 bitbucket login --global --url https://bitbucket.example.com # Pin defaults so subsequent calls don't need project/repo c3 bitbucket set-default --project PROJ --repo my-service @@ -232,6 +235,16 @@ c3 bitbucket set-default --project PROJ --repo my-service c3 bitbucket status ``` +**Account resolution precedence:** the project's `.c3/config.json` wins, but when +it has no active account C3 falls back to the global `~/.c3/config.json`. So a +single `login --global` (or any login done from your home directory) is reusable +across every C3 project — the PAT always lives in the OS keyring, never on disk. + +> **Upgrading:** stop the running `c3-mcp` server / CLI before `c3 upgrade`. A live +> process can hold package files open, leaving pip's `~`-prefixed backup dirs +> (`~ervices`, `~ools`, …) in `site-packages`; those are inert and safe to delete +> after the upgrade completes. + The MCP tool dispatches by `action`. Read-only actions: `status`, `whoami`, `list_projects`, `list_repos`, `get_repo`, `list_prs`, `get_pr`, `get_pr_diff`, `get_pr_activities`, `list_branches`, `list_commits`, `list_activity`, diff --git a/cli/c3.py b/cli/c3.py index 3952808..acd67e6 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -85,7 +85,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.39.0" +__version__ = "2.40.0" def _command_deps() -> CommandDeps: @@ -5577,14 +5577,19 @@ def _bb_cmd_login(args, project_path: str) -> None: from services import bitbucket_credentials as bb_creds from services.bitbucket_client import BitbucketDataCenterClient, BitbucketError + # --global stores the account in ~/.c3/config.json so it is reusable in + # every C3 project (load_bitbucket_config falls back to it automatically). + if getattr(args, "use_global", False): + project_path = str(Path.home()) + base_url = (args.url or "").rstrip("/") username = args.username or input(f"Username for {base_url}: ").strip() if not username: - print("Login cancelled — username required.") + print("Login cancelled -- username required.") return token = args.token or getpass.getpass(f"Personal Access Token for {username}: ").strip() if not token: - print("Login cancelled — token required.") + print("Login cancelled -- token required.") return try: @@ -5600,10 +5605,13 @@ def _bb_cmd_login(args, project_path: str) -> None: if getattr(args, "insecure", False): bb_creds.set_verify_tls(False, project_path=project_path) - print(f"[OK] Stored credentials for {username}@{base_url}") + scope = "global (~/.c3)" if getattr(args, "use_global", False) else "project" + print(f"[OK] Stored credentials for {username}@{base_url} [{scope}]") - # Connection probe — non-fatal if it fails (token might be valid but - # network blocked at this moment). + # Connection probe -- non-fatal if it fails (token might be valid but the + # network is blocked right now). Gate success on application-properties + # only; whoami enrichment is best-effort so a valid login never prints a + # failure (Bitbucket DC has no /users/me). try: client = BitbucketDataCenterClient( base_url=base_url, token=token, @@ -5611,12 +5619,20 @@ def _bb_cmd_login(args, project_path: str) -> None: ) props = client.application_properties() version = props.get("version", "?") - user = client.whoami() print(f" Server: {version} ({base_url})") - print(f" Auth as: {user.get('displayName', username)} <{user.get('emailAddress', '?')}>") except BitbucketError as exc: print(f"[warn] Connection probe failed: {exc}") - print(" Token saved anyway — re-test with `c3 bitbucket status`.") + print(" Token saved anyway -- re-test with `c3 bitbucket status`.") + return + try: + user = client.whoami() + if user: + print( + f" Auth as: {user.get('displayName', username)} " + f"<{user.get('emailAddress', '?')}>" + ) + except BitbucketError: + pass def _bb_cmd_logout(args, project_path: str) -> None: @@ -5661,7 +5677,7 @@ def _bb_cmd_status(args, project_path: str) -> None: return token = bb_creds.load_token(active["base_url"], active["username"]) if not token: - print(" Connection: FAIL — no token in keyring") + print(" Connection: FAIL -- no token in keyring") return try: client = BitbucketDataCenterClient( @@ -5671,7 +5687,7 @@ def _bb_cmd_status(args, project_path: str) -> None: props = client.application_properties() print(f" Connection: OK (version {props.get('version','?')})") except BitbucketError as exc: - print(f" Connection: FAIL — {exc}") + print(f" Connection: FAIL -- {exc}") def _bb_cmd_use(args, project_path: str) -> None: @@ -6571,6 +6587,15 @@ def _launch_tui() -> None: def main(): + # Force UTF-8 on the CLI streams so server-supplied text (PR titles, branch + # names, diffs) and our own glyphs render cleanly on Windows cp1252 consoles + # instead of raising UnicodeEncodeError or mojibaking. + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + try: from services import error_reporting error_reporting.init(component="c3-cli", version=__version__) diff --git a/cli/commands/parser.py b/cli/commands/parser.py index 7b8ec8f..b578f28 100644 --- a/cli/commands/parser.py +++ b/cli/commands/parser.py @@ -303,6 +303,7 @@ def build_parser(version: str, parse_cli_ide_arg): bb_login.add_argument("--token", help="Personal Access Token (prompted via getpass if omitted — preferred)") bb_login.add_argument("--no-set-active", action="store_true", help="Do not switch the active account to this one") bb_login.add_argument("--insecure", action="store_true", help="Disable TLS verification (self-signed certs)") + bb_login.add_argument("--global", dest="use_global", action="store_true", help="Store the account in the global ~/.c3/config.json so it is reusable in every C3 project") bb_login.add_argument("project_path", nargs="?", default=".") bb_logout = bb_subs.add_parser("logout", help="Remove a Bitbucket account from keyring + config") diff --git a/cli/tools/bitbucket.py b/cli/tools/bitbucket.py index 572d076..5c77721 100644 --- a/cli/tools/bitbucket.py +++ b/cli/tools/bitbucket.py @@ -50,7 +50,12 @@ def _cap(resp: str) -> str: candidate = "\n".join(lines) + "\n[truncated]" if count_tokens(candidate) <= _RESPONSE_TOKEN_CAP: return candidate - return "\n".join(lines[:20]) + "\n[truncated]" + # A single over-long line never splits above; hard-clamp by characters as a + # final guard (~4 chars/token) so one huge line can't blow past the cap. + head = "\n".join(lines[:20]) + if count_tokens(head) > _RESPONSE_TOKEN_CAP: + head = head[:_RESPONSE_TOKEN_CAP * 4] + return head + "\n[truncated]" def _build_client(svc) -> tuple[BitbucketDataCenterClient | None, str]: @@ -111,20 +116,20 @@ def _format_pr(pr: dict) -> str: author = (pr.get("author") or {}).get("user", {}).get("name", "?") src = (pr.get("fromRef") or {}).get("displayId", "?") dst = (pr.get("toRef") or {}).get("displayId", "?") - return f" #{pr_id} [{state:7}] {src} → {dst} by {author}\n {title}" + return f" #{pr_id} [{state:7}] {src} -> {dst} by {author}\n {title}" def _format_pr_full(pr: dict) -> str: lines = [ - f"PR #{pr.get('id')} [{pr.get('state')}] — {pr.get('title','')}", - f" {(pr.get('fromRef') or {}).get('displayId')} → {(pr.get('toRef') or {}).get('displayId')}", + f"PR #{pr.get('id')} [{pr.get('state')}] -- {pr.get('title','')}", + f" {(pr.get('fromRef') or {}).get('displayId')} -> {(pr.get('toRef') or {}).get('displayId')}", f" Author: {(pr.get('author') or {}).get('user',{}).get('displayName','?')}", f" Version: {pr.get('version')} | Open tasks: {pr.get('openTaskCount', 0)}", ] reviewers = pr.get("reviewers") or [] if reviewers: rs = ", ".join( - f"{r.get('user',{}).get('name','?')}({'✓' if r.get('approved') else '·'})" + f"{r.get('user',{}).get('name','?')}({'[x]' if r.get('approved') else '[ ]'})" for r in reviewers ) lines.append(f" Reviewers: {rs}") @@ -190,7 +195,7 @@ def _act_status(client: BitbucketDataCenterClient | None, err: str, svc) -> str: version = props.get("version", "?") out.append(f" Server : OK (version {version})") except BitbucketError as exc: - out.append(f" Server : FAIL — {exc}") + out.append(f" Server : FAIL -- {exc}") return "\n".join(out) @@ -214,7 +219,7 @@ def _act_list_projects(client: BitbucketDataCenterClient, kwargs: dict) -> str: for p in projects[:50]: lines.append(f" {p.get('key','?'):16} {p.get('name','?')}") if len(projects) > 50: - lines.append(f" … +{len(projects) - 50} more") + lines.append(f" ... +{len(projects) - 50} more") return "\n".join(lines) @@ -222,11 +227,11 @@ def _act_list_repos(client: BitbucketDataCenterClient, project: str) -> str: repos = client.list_repos(project) if not repos: return f"[bitbucket:repos] {project}: (none)" - lines = [f"[bitbucket:repos] {project} — {len(repos)} repo(s)"] + lines = [f"[bitbucket:repos] {project} - {len(repos)} repo(s)"] for r in repos[:80]: lines.append(f" {r.get('slug','?'):30} {r.get('name','?')}") if len(repos) > 80: - lines.append(f" … +{len(repos) - 80} more") + lines.append(f" ... +{len(repos) - 80} more") return "\n".join(lines) @@ -252,7 +257,7 @@ def _act_list_prs(client, project: str, repo: str, kwargs: dict) -> str: ) if not prs: return f"[bitbucket:prs] {project}/{repo} state={state}: (none)" - out = [f"[bitbucket:prs] {project}/{repo} state={state} — {len(prs)} PR(s)"] + out = [f"[bitbucket:prs] {project}/{repo} state={state} - {len(prs)} PR(s)"] for pr in prs: out.append(_format_pr(pr)) return "\n".join(out) @@ -266,7 +271,7 @@ def _act_get_pr(client, project: str, repo: str, pr_id: int) -> str: def _act_get_pr_diff(client, project: str, repo: str, pr_id: int, kwargs: dict) -> str: diff = client.get_pr_diff(project, repo, pr_id, context_lines=int(kwargs.get("context_lines", 3))) if len(diff) > _DIFF_PREVIEW_CHARS: - diff = diff[:_DIFF_PREVIEW_CHARS] + "\n… [diff truncated]" + diff = diff[:_DIFF_PREVIEW_CHARS] + "\n... [diff truncated]" return f"[bitbucket:diff] {project}/{repo}#{pr_id}\n{diff}" @@ -274,7 +279,7 @@ def _act_get_pr_activities(client, project: str, repo: str, pr_id: int) -> str: acts = client.get_pr_activities(project, repo, pr_id) if not acts: return f"[bitbucket:pr-activity] {project}/{repo}#{pr_id}: (none)" - out = [f"[bitbucket:pr-activity] {project}/{repo}#{pr_id} — {len(acts)} event(s)"] + out = [f"[bitbucket:pr-activity] {project}/{repo}#{pr_id} - {len(acts)} event(s)"] for a in acts[:50]: out.append(_format_activity(a)) return "\n".join(out) @@ -341,11 +346,11 @@ def _act_list_branches(client, project: str, repo: str, kwargs: dict) -> str: branches = client.list_branches(project, repo, filter_text=kwargs.get("filter", "")) if not branches: return f"[bitbucket:branches] {project}/{repo}: (none)" - out = [f"[bitbucket:branches] {project}/{repo} — {len(branches)} branch(es)"] + out = [f"[bitbucket:branches] {project}/{repo} - {len(branches)} branch(es)"] for b in branches[:80]: out.append(_format_branch(b)) if len(branches) > 80: - out.append(f" … +{len(branches) - 80} more") + out.append(f" ... +{len(branches) - 80} more") return "\n".join(out) @@ -378,7 +383,7 @@ def _act_list_commits(client, project: str, repo: str, kwargs: dict) -> str: ) if not commits: return f"[bitbucket:commits] {project}/{repo}: (none)" - out = [f"[bitbucket:commits] {project}/{repo} — {len(commits)} commit(s)"] + out = [f"[bitbucket:commits] {project}/{repo} - {len(commits)} commit(s)"] for c in commits: out.append(_format_commit(c)) return "\n".join(out) @@ -388,7 +393,7 @@ def _act_list_activity(client, project: str, repo: str, kwargs: dict) -> str: acts = client.list_repo_activities(project, repo, limit=int(kwargs.get("limit", 30))) if not acts: return f"[bitbucket:activity] {project}/{repo}: (none)" - out = [f"[bitbucket:activity] {project}/{repo} — {len(acts)} event(s)"] + out = [f"[bitbucket:activity] {project}/{repo} - {len(acts)} event(s)"] for a in acts: out.append(_format_commit(a)) return "\n".join(out) @@ -401,7 +406,7 @@ def _act_build_status(client, kwargs: dict) -> str: builds = client.get_build_status(commit) if not builds: return f"[bitbucket:build_status] {commit[:12]}: (none)" - out = [f"[bitbucket:build_status] {commit[:12]} — {len(builds)} build(s)"] + out = [f"[bitbucket:build_status] {commit[:12]} - {len(builds)} build(s)"] for b in builds: out.append(_format_build(b)) return "\n".join(out) @@ -429,10 +434,10 @@ def _act_list_webhooks(client, project: str, repo: str) -> str: hooks = client.list_webhooks(project, repo) if not hooks: return f"[bitbucket:webhooks] {project}/{repo}: (none)" - out = [f"[bitbucket:webhooks] {project}/{repo} — {len(hooks)} hook(s)"] + out = [f"[bitbucket:webhooks] {project}/{repo} - {len(hooks)} hook(s)"] for h in hooks: out.append( - f" #{h.get('id')} [{'on' if h.get('active') else 'off'}] {h.get('name','?')} → {h.get('url','?')}" + f" #{h.get('id')} [{'on' if h.get('active') else 'off'}] {h.get('name','?')} -> {h.get('url','?')}" ) evs = h.get("events") or [] if evs: @@ -454,7 +459,7 @@ def _act_create_webhook(client, project: str, repo: str, kwargs: dict) -> str: active=bool(kwargs.get("active", True)), secret=kwargs.get("secret", ""), ) - return f"[bitbucket:webhook-created] {project}/{repo} #{res.get('id','?')} {name} → {url}" + return f"[bitbucket:webhook-created] {project}/{repo} #{res.get('id','?')} {name} -> {url}" def _act_delete_webhook(client, project: str, repo: str, kwargs: dict) -> str: diff --git a/core/config.py b/core/config.py index be2272a..2c789c3 100644 --- a/core/config.py +++ b/core/config.py @@ -290,15 +290,41 @@ def load_agent_config(project_path: str) -> dict: } +def _read_bitbucket_section(config_file: Path) -> dict: + """Return the ``bitbucket`` section of a config file, or ``{}``.""" + if not config_file.exists(): + return {} + try: + with open(config_file, encoding="utf-8") as f: + data = json.load(f) + except Exception: + return {} + section = data.get("bitbucket", {}) + return section if isinstance(section, dict) else {} + + def load_bitbucket_config(project_path: str) -> dict: - """Load Bitbucket config from .c3/config.json, merged with defaults.""" - config_file = Path(project_path) / ".c3" / "config.json" - overrides = {} - if config_file.exists(): + """Load Bitbucket config from .c3/config.json, merged with defaults. + + Resolution precedence: the project ``/.c3/config.json`` wins, but + when it has no active account we fall back to the global + ``~/.c3/config.json`` so a one-time ``c3 bitbucket login`` (or + ``login --global``) is reusable across every C3 project. The PAT itself + always lives in the OS keyring, never in these files. + """ + project_file = Path(project_path) / ".c3" / "config.json" + overrides = _read_bitbucket_section(project_file) + if not (overrides.get("active") or {}).get("base_url"): + # Path.home() raises RuntimeError when no home dir is resolvable (e.g. + # a stripped subprocess env); treat that as "no global fallback". try: - with open(config_file, encoding="utf-8") as f: - data = json.load(f) - overrides = data.get("bitbucket", {}) + home_file = Path.home() / ".c3" / "config.json" + already_home = home_file.resolve() == project_file.resolve() except Exception: - pass + home_file = None + already_home = True + if home_file is not None and not already_home: + home_overrides = _read_bitbucket_section(home_file) + if (home_overrides.get("active") or {}).get("base_url"): + overrides = home_overrides return {**BITBUCKET_DEFAULTS, **overrides} diff --git a/pyproject.toml b/pyproject.toml index 5f9c60b..9d581b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.39.1" +version = "2.40.0" description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer." readme = "README.md" requires-python = ">=3.10" diff --git a/services/bitbucket_client.py b/services/bitbucket_client.py index 5fd2c51..473891e 100644 --- a/services/bitbucket_client.py +++ b/services/bitbucket_client.py @@ -28,7 +28,15 @@ _API = "/rest/api/1.0" _BUILD_API = "/rest/build-status/1.0" _BRANCH_UTILS = "/rest/branch-utils/1.0" -_USER_AGENT = "c3-bitbucket/2.30.0" + +try: + from importlib.metadata import version as _pkg_version + + _C3_VERSION = _pkg_version("code-context-control") +except Exception: # pragma: no cover - package metadata may be unavailable + _C3_VERSION = "0.0.0" + +_USER_AGENT = f"c3-bitbucket/{_C3_VERSION}" class BitbucketError(RuntimeError): @@ -80,6 +88,8 @@ def _request( params: dict | None = None, api_root: str = _API, raw: bool = False, + accept: str = "application/json", + return_headers: bool = False, ) -> Any: full_path = f"{api_root}{path}" if params: @@ -92,7 +102,7 @@ def _request( headers = { "Authorization": f"Bearer {self._token}", - "Accept": "application/json", + "Accept": accept, "User-Agent": _USER_AGENT, } data: bytes | None = None @@ -109,14 +119,19 @@ def _request( try: with urllib.request.urlopen(req, timeout=self._timeout, context=self._ssl_context()) as resp: payload = resp.read() + resp_headers = {k: v for k, v in resp.headers.items()} if raw: - return payload - if not payload: - return {} - try: - return json.loads(payload.decode("utf-8")) - except json.JSONDecodeError: - return {"raw": payload.decode("utf-8", errors="replace")} + body_out: Any = payload + elif not payload: + body_out = {} + else: + try: + body_out = json.loads(payload.decode("utf-8")) + except json.JSONDecodeError: + body_out = {"raw": payload.decode("utf-8", errors="replace")} + if return_headers: + return body_out, resp_headers + return body_out except urllib.error.HTTPError as exc: try: err_payload = json.loads(exc.read().decode("utf-8")) @@ -168,8 +183,27 @@ def application_properties(self) -> dict: return self._request("GET", "/application-properties") def whoami(self) -> dict: - """The authenticated user (PAT owner).""" - return self._request("GET", "/users/me") + """The authenticated user (PAT owner). + + Bitbucket Data Center / Server has no ``/users/me`` endpoint (that is a + Cloud convention; DC treats ``me`` as a literal username and 404s). + Resolve the account from the ``X-AUSERNAME`` header that rides on every + authenticated response, then enrich with the user record when possible. + """ + _body, headers = self._request( + "GET", "/application-properties", return_headers=True + ) + slug = "" + for key, value in (headers or {}).items(): + if key.lower() == "x-ausername": + slug = urllib.parse.unquote(value or "") + break + if not slug: + return {} + try: + return self._request("GET", f"/users/{urllib.parse.quote(slug)}") + except BitbucketError: + return {"name": slug, "slug": slug, "displayName": slug} # ── Projects & repos (read) ─────────────────────────── @@ -282,6 +316,7 @@ def get_pr_diff( f"/projects/{project_key}/repos/{repo_slug}/pull-requests/{pr_id}/diff", params={"contextLines": context_lines}, raw=True, + accept="text/plain", ) if isinstance(payload, bytes): return payload.decode("utf-8", errors="replace") diff --git a/services/claude_md.py b/services/claude_md.py index 1abbe32..a9ee1f8 100644 --- a/services/claude_md.py +++ b/services/claude_md.py @@ -40,7 +40,7 @@ 7. **VALIDATE**: `c3_validate(file_path)` — after edits or before reporting done. Runs deep type check (pyright/tsc) automatically if installed 8. **LOG**: `c3_session(action='log')` for decisions. `c3_session(action='snapshot')` before /clear 9. **DELEGATE**: `c3_delegate(task, backend='ollama|codex|gemini|claude|auto')` or `c3_agent(workflow=...)` for multi-model pipelines -10. **BITBUCKET** (when configured, v2.30.0+): `c3_bitbucket(action='...')` — for self-hosted enterprise Bitbucket Data Center / Server: PRs, branches, builds, repo admin. Tokens live in the OS keyring (set up via `c3 bitbucket login`). Read actions are safe in plan mode; write actions (`merge_pr`, `create_branch`, etc.) are auto-logged to the edit ledger. +10. **BITBUCKET** (when configured, v2.30.0+): `c3_bitbucket(action='...')` — for self-hosted enterprise Bitbucket Data Center / Server: PRs, branches, builds, repo admin. Tokens live in the OS keyring (set up via `c3 bitbucket login`, or `login --global` for a home config reusable across projects; account resolution precedence is project → home). Read actions are safe in plan mode; write actions (`merge_pr`, `create_branch`, etc.) are auto-logged to the edit ledger. 11. **CROSS-PROJECT** (v2.31.0+): `c3_project(action='list|scan|info|search|read|edit|shell|...', project='')` — discover and operate on OTHER c3-installed projects. `list`/`scan` need no project; reads (search/read/compress/status/memory/impact/edits/validate/filter) run freely; writes (`edit`, `shell`, memory add/update/delete) require `allow_write=true` and are logged to the target project's ledger. ## Plan mode diff --git a/tests/test_bitbucket_client.py b/tests/test_bitbucket_client.py index 413870b..025fea0 100644 --- a/tests/test_bitbucket_client.py +++ b/tests/test_bitbucket_client.py @@ -24,8 +24,9 @@ class _Resp: """Tiny stand-in for the urllib response context manager.""" - def __init__(self, payload: bytes): + def __init__(self, payload: bytes, headers: dict | None = None): self._payload = payload + self.headers = headers or {} def __enter__(self): return self @@ -37,8 +38,8 @@ def read(self): return self._payload -def _ok(json_obj): - return _Resp(json.dumps(json_obj).encode("utf-8")) +def _ok(json_obj, headers: dict | None = None): + return _Resp(json.dumps(json_obj).encode("utf-8"), headers=headers) class TestBitbucketClient(unittest.TestCase): @@ -62,6 +63,7 @@ def test_application_properties_uses_bearer_auth(self): def fake_urlopen(req, timeout=None, context=None): captured["url"] = req.full_url captured["auth"] = req.get_header("Authorization") + captured["accept"] = req.get_header("Accept") captured["method"] = req.get_method() return _ok({"version": "8.5.0", "displayName": "Bitbucket"}) @@ -70,6 +72,7 @@ def fake_urlopen(req, timeout=None, context=None): self.assertEqual(res["version"], "8.5.0") self.assertEqual(captured["auth"], "Bearer t0k3n") + self.assertEqual(captured["accept"], "application/json") self.assertEqual(captured["method"], "GET") self.assertIn("/rest/api/1.0/application-properties", captured["url"]) @@ -144,15 +147,21 @@ def fake_urlopen(req, timeout=None, context=None): self.assertEqual(res["state"], "MERGED") self.assertIn("version=4", captured["url"]) - def test_get_pr_diff_returns_text(self): + def test_get_pr_diff_requests_text_plain(self): diff_bytes = b"diff --git a/x b/x\n+y\n" + captured = {} def fake_urlopen(req, timeout=None, context=None): + captured["accept"] = req.get_header("Accept") + captured["url"] = req.full_url return _Resp(diff_bytes) with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): text = self.client.get_pr_diff("PROJ", "repo", 1) self.assertIn("+y", text) + # Issue 1: the diff must be content-negotiated as text/plain, not JSON. + self.assertEqual(captured["accept"], "text/plain") + self.assertIn("/pull-requests/1/diff", captured["url"]) def test_http_error_translated_to_bitbucket_error(self): import urllib.error @@ -198,6 +207,56 @@ def fake_urlopen(req, timeout=None, context=None): self.assertIn("version=2", captured["url"]) self.assertIn("/decline", captured["url"]) + def test_whoami_resolves_via_xausername_header(self): + # Issue 2: Data Center has no /users/me; whoami must read X-AUSERNAME + # from an authenticated response and enrich via /users/{slug}. + calls = [] + + def fake_urlopen(req, timeout=None, context=None): + calls.append(req.full_url) + if req.full_url.endswith("/application-properties"): + return _Resp( + json.dumps({"version": "9.4.0"}).encode("utf-8"), + headers={"X-AUSERNAME": "jdoe"}, + ) + if "/users/" in req.full_url: + return _ok( + { + "name": "jdoe", + "displayName": "Jane Doe", + "emailAddress": "jdoe@example.com", + } + ) + return _ok({}) + + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + user = self.client.whoami() + + self.assertEqual(user.get("displayName"), "Jane Doe") + self.assertTrue(any(u.endswith("/application-properties") for u in calls)) + self.assertTrue(any("/users/jdoe" in u for u in calls)) + self.assertFalse(any("/users/me" in u for u in calls)) + + def test_whoami_without_header_returns_empty(self): + def fake_urlopen(req, timeout=None, context=None): + return _ok({"version": "9.4.0"}) # no X-AUSERNAME header + + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + self.assertEqual(self.client.whoami(), {}) + + def test_user_agent_reflects_package_version(self): + # Issue 5: User-Agent must not be the stale hardcoded 2.30.0 literal. + captured = {} + + def fake_urlopen(req, timeout=None, context=None): + captured["ua"] = req.get_header("User-agent") + return _ok({"version": "9.4.0"}) + + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + self.client.application_properties() + self.assertTrue(captured["ua"].startswith("c3-bitbucket/")) + self.assertNotEqual(captured["ua"], "c3-bitbucket/2.30.0") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_bitbucket_config_fallback.py b/tests/test_bitbucket_config_fallback.py new file mode 100644 index 0000000..42bf8ca --- /dev/null +++ b/tests/test_bitbucket_config_fallback.py @@ -0,0 +1,70 @@ +"""Issue 3: load_bitbucket_config falls back to the global ~/.c3/config.json. + +A one-time `c3 bitbucket login` (or `login --global`) must be reusable from a +different C3 project that has never run login, while a project that does have an +active account still takes precedence. +""" +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from core import config as core_config + + +def _write_cfg(root: Path, section: dict) -> None: + c3 = root / ".c3" + c3.mkdir(parents=True, exist_ok=True) + (c3 / "config.json").write_text( + json.dumps({"bitbucket": section}), encoding="utf-8" + ) + + +class TestBitbucketConfigFallback(unittest.TestCase): + def setUp(self): + self._proj = tempfile.TemporaryDirectory() + self._home = tempfile.TemporaryDirectory() + self.proj = Path(self._proj.name) + self.home = Path(self._home.name) + self._home_patcher = mock.patch.object(Path, "home", return_value=self.home) + self._home_patcher.start() + + def tearDown(self): + self._home_patcher.stop() + self._proj.cleanup() + self._home.cleanup() + + def test_falls_back_to_home_when_project_has_no_active(self): + _write_cfg(self.proj, {"active": {"base_url": "", "username": ""}}) + _write_cfg( + self.home, {"active": {"base_url": "https://bb", "username": "alice"}} + ) + cfg = core_config.load_bitbucket_config(str(self.proj)) + self.assertEqual( + cfg["active"], {"base_url": "https://bb", "username": "alice"} + ) + + def test_project_active_takes_precedence_over_home(self): + _write_cfg( + self.proj, {"active": {"base_url": "https://proj", "username": "bob"}} + ) + _write_cfg( + self.home, {"active": {"base_url": "https://bb", "username": "alice"}} + ) + cfg = core_config.load_bitbucket_config(str(self.proj)) + self.assertEqual(cfg["active"]["base_url"], "https://proj") + + def test_no_configs_returns_defaults(self): + cfg = core_config.load_bitbucket_config(str(self.proj)) + self.assertEqual(cfg["active"], {"base_url": "", "username": ""}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_bitbucket_tool.py b/tests/test_bitbucket_tool.py index db9dc54..af40142 100644 --- a/tests/test_bitbucket_tool.py +++ b/tests/test_bitbucket_tool.py @@ -174,6 +174,16 @@ def test_repo_scoped_action_without_defaults_errors(self): res = tool.handle_bitbucket("list_branches", self.svc, finalize) self.assertIn("project and repo are required", res) + def test_cap_clamps_single_overlong_line(self): + # Issue 7: one line with no newlines must still be clamped by chars. + long_line = "x" * 200_000 + out = tool._cap(long_line) + self.assertTrue(out.endswith("[truncated]")) + self.assertLessEqual( + len(out), tool._RESPONSE_TOKEN_CAP * 4 + len("\n[truncated]") + ) + self.assertLess(len(out), len(long_line)) + if __name__ == "__main__": unittest.main()