Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`,
Expand Down
47 changes: 36 additions & 11 deletions cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -5600,23 +5605,34 @@ 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,
verify_tls=not getattr(args, "insecure", False),
)
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:
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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__)
Expand Down
1 change: 1 addition & 0 deletions cli/commands/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
45 changes: 25 additions & 20 deletions cli/tools/bitbucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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)


Expand All @@ -214,19 +219,19 @@ 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)


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)


Expand All @@ -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)
Expand All @@ -266,15 +271,15 @@ 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}"


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)
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading
Loading