Skip to content

feat: Bitbucket Cloud pull requests - #696

Merged
Tryanks merged 8 commits into
mainfrom
feat/bitbucket-host
Oct 11, 2026
Merged

Tryanks merged 8 commits into
mainfrom
feat/bitbucket-host

Conversation

@Tryanks

@Tryanks Tryanks commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Summary

HostKind::Bitbucket (core)          bitbucket.org only; a "bitbucket" DNS label is Data Center → not Bitbucket
  terms: "Bitbucket", no CLI, no stacks, mark icons/bitbucket.svg, "#" numbers
  authority(): any other host → HostRefusal::Fixed ("Use bitbucket.org: Tcode reads Bitbucket Cloud only.")
  fixed_host(), takes_email()        read by the UI instead of branching on the kind
credentials                          saved API token + Atlassian email → Basic, or access token → Bearer;
                                     saving one method clears the other; then BITBUCKET_TOKEN (+ BITBUCKET_EMAIL)
                                     only for bitbucket.org; SetHostToken { host, token, email? }
services/src/bitbucket/              REST 2.0 at api.bitbucket.org
  api.rs          transport: credential only to api.bitbucket.org and, for media named in the conversation,
                  to bitbucket.org; same-origin redirects (/diff, /diffstat,
                  /conflicts answer 302), next-page cursors checked to be the API's, 429 → X-RateLimit-Reset
  repository.rs   https://bitbucket.org/{ws}/{repo}/pull-requests/{N}; https / user@ / ssh / scp remotes
  reads.rs        PR, statuses, conflicts, comments → threads by parent, activity → remarks
  actions.rs      comment, reply, resolve, edit, title/description, reviewers, draft, decline, merge, review
  mod.rs          Forge impl
forge/{checkout,anchors}.rs, forge::revisions   moved from Forgejo/GitLab (both had identical copies)
Hosts dispatcher                     + bitbucket
UI                                   Add host: Bitbucket with bitbucket.org fixed and disabled;
                                     Bitbucket row: [API token | Access token] editor; close dialog says
                                     "%{host_name} can't reopen it afterwards." where reopen is absent

Evidence

  • Before: a bitbucket.org link was read as a GitHub host and failed; Add host had no Bitbucket.
    After:
Desktop: Source Control Desktop: Add host, Bitbucket
Desktop: Bitbucket PR, conversation Desktop: Bitbucket PR, files
Phone: Source Control (API token) Phone: Source Control (access token)

The profile is a fresh TCODE_DATA_DIR with a throwaway HOME and HOSTNAME="Demo Mac", in English and light. It has a synthetic project (sample-app, remote git@bitbucket.org:atlassian/atlassian-connect-express.git). Its one thread failed to start because no provider is on PATH, and it links public PR atlassian/atlassian-connect-express#547 anonymously through the link dialog. That PR shows 8 conversation remarks, reviewers with approvals, 8 files with hunks and per-file counts, and "Viewed marks kept by Tcode". The first open of Files stayed on its skeleton while the window was unfocused and redrew when the window came to the front, so the background frame throttle explains it.

The PR-page shots show public content (names, avatars, text) of the public PR atlassian/atlassian-connect-express#547, not the maintainer's data. The conversation shot was taken with the window unfocused (grey traffic lights); it was not retaken because the anonymous budget was spent.

Live reads on bitbucket.org through Tcode's host. These used forge::connect driven by a throwaway probe (not committed), anonymously. Counts are bitbucket path=… request log lines.

  • atlassian/atlassian-connect-express#547 (merged; inline threads; one build status):
    • summary: 2 requests (Merged, checks Passing, head d0a06076aec9)
    • conversation: 2 (8 comments, 5 threads all resolved, 22 thread comments, 4 marked edited, complete; reviewers Approved; anonymous → no verdicts; account bitbucket.org:anonymous; first thread anchored New side at line 237, revision d0a06076aec9)
    • files: 4 (PR, /merge-base, /diff 302 → same-origin /diff/… 200; 8 files, complete, base the merge base b58dfbb…)
    • file text: 1 (/src/{sha}/README.md, 830 lines)
    • viewed files: 2 (the diff is cached per head; 8 index-line revisions, complete)
    • reviewer candidates: 3 (/workspaces/atlassian/members answers 401 anonymously → 5 candidates, incomplete)
    • action state: 2 (merged → merge state Unknown, no conflicts read)
    • watch detail: 2 (Merged, head sha, one check Success)
    • activity: 2 pages (33 remarks, 3 votes)
  • atlassian/aui#5381 (open draft with conflicts and failing builds):
    • summary: 2 (Open, draft, checks Failing)
    • action state: 4 (PR, statuses, /conflicts 302 → /file-conflicts/… 200; merge state Draft, 5 failing checks)
    • watch detail: 4. The first read gives mergeability Unknown and a second read 31 s later in the same process gives Conflicting, so the shared Verdicts rule holds a conflict for 30 s at one head.
  • Anonymous Bitbucket allows 60 requests an hour (x-ratelimit-limit: 60, 60;w=3600). Its 429s carry no Retry-After, only X-RateLimit-Reset (seconds left in the window), so the host now pauses until then. While paused, Tcode refused the next read before sending it (Paused). The PR page shows "Bitbucket rate limit reached. Status resumes soon."

Writes: unverified. This machine has no BITBUCKET_TOKEN and no Bitbucket login, so no scratch repository tcode-a7-bitbucket-check was created and nothing needed deleting. None of these were checked against a live server: comment, reply (parent.id), resolve/reopen (POST/DELETE …/comments/{id}/resolve), edit comment, edit title/description (partial PUT), reviewers (whole-list PUT checked against the answer), draft/ready (PUT {draft}), decline, merge (merge_commit | squash | rebase_fast_forward, close_source_branch as the PR has it), and review (inline comments with to/from and start_*, then the summary, then /approve or /request-changes). Basic and Bearer authentication and /user with an access token were not checked live either.

Deviations from the brief, with evidence:

  • resolve: capability true. The brief assumed no resolve API. Bitbucket comments carry resolution (seen live: {"type":"comment_resolution","user":…,"created_on":…} on perf(chat): virtualize the timeline by segment, not by turn #547's thread roots), and upstream resolves by creating and deleting …/comments/{id}/resolve (BitbucketPullRequestApi.ts:933). The A7 design table marks Bitbucket resolve ✓.
  • mergeability from /conflicts, not "always Unknown". The endpoint exists, is what upstream uses, and was verified live: it answers with each conflicting path (aui#5381) or an empty list. A permanent Unknown would keep the watch in the 2-minute in-flight cadence, which Source control: other hosts, Forgejo/Gitea, GitLab, Bitbucket, Azure DevOps (A7) #643's Decisions rule out. The summary still makes 2 reads (PR plus statuses) and leaves conflicts to the detail and watch reads.
  • Rebase → rebase_fast_forward (upstream BitbucketPullRequestApi.ts:385-395) rather than fast_forward, which refuses any branch that is not already up to date. This is unverified live.
  • merge_message: false. /merge takes a message, but no read gives the message Bitbucket would write, so there is nothing to clean of agent credits. The credits row is absent, and the Settings description now says Bitbucket always uses the server's message.

Left out because the API cannot do it (no substitute built): reactions (none), labels (none; permission label false), reopen (a decline is final), auto-merge, update branch, behind_by. Bitbucket's merge checks (approvals, tasks) have no documented read, so a PR they block reads Clean and the merge answers with Bitbucket's refusal. Bitbucket inline comments name no comment-time commit, so a thread's anchor uses the revisions its links.code compares and outdated stays false. Media is read only for https://bitbucket.org/repo/… and /{ws}/{repo}/… URLs named in the conversation. Avatars live on atl-paas.net and are drawn by URL.

Tests (all new or extended; none deleted):

  • core the_model_reads_the_hosts_settings_configure: the GitHub-only linking text is still the literal; Bitbucket adds "GitHub or Bitbucket" and no CLI.
  • core each_kind_names_its_hosts_by_its_own_rule: bitbucket.org is accepted; other hosts, ports and paths are refused with Fixed; a bitbucket label is not detected as Bitbucket.
  • core URL-hint test: a Bitbucket PR URL is a hint, and one on another host is not.
  • forge each_host_goes_to_its_kinds_implementation: bitbucket.org routes by name; a Data Center name stays GitHub's.
  • forge a_declined_bitbucket_pull_request_is_not_reopened: Reopen → Unsupported before any request.
  • bitbucket a_credential_is_the_saved_method_then_the_environment: Basic vs Bearer, saving one clears the other, an email alone never turns an access token into an API token, env only for bitbucket.org, header-unsafe token never sent.
  • a_cursor_is_only_a_page_of_the_api: off-origin, http, other-port and non-/2.0 cursors are refused.
  • urls_and_remotes_name_the_repository_on_bitbucket_org.
  • build_statuses_read_as_checks: rerun key, STOPPED → failing, none → no state.
  • line_comments_and_their_replies_are_one_thread: deep replies, deleted reply, resolution is not an edit, side and range, the author may only comment.
  • a_reviewer_change_is_what_bitbucket_answers.
  • bitbucket_org_is_listed_once_something_names_it.

Review fixes (round 2)

  • M1: the Close confirmation no longer promises a reopen it cannot see. Opened from a list-row menu before the action state was read (Offer.action is None), it now reads the action state first, as the merge dialog does. It then says "You can reopen it later." only when capabilities.reopen is true, and "%{host_name} can't reopen it afterwards." when it is false. When the read fails it says "It may not be reopenable on %{host_name}." (new string close_desc_unknown, en and zh-CN). No test was added: lifecycle.rs has no dialog test seam.
  • M2: /conflicts is not in Bitbucket's published API, so its NotFound, Refused and Uncertain answers now leave mergeability Unknown instead of failing the action-state or watch read. RateLimited, Paused, Unauthorized and HostDisabled still propagate.
  • M3: Bitbucket allows 60 anonymous requests an hour. In steady state one open linked pull request costs 2 requests a minute for the summary, and its checkout costs 1 a minute for discovery: 3 a minute, so the budget is gone in about 20 minutes. While no credential reads, the host now keeps a summary and a branch lookup for 10 minutes (Bitbucket::sync_ttl, the one owner, chosen by credential presence; 60 s with a credential). That costs 3 requests per 10 minutes, or 18 an hour, for one PR and its checkout. Opening the detail page still spends its own reads. A write or invalidate drops the kept summary. Saving a token drops the kept summaries and branch lookups, so the new credential applies at once.
  • L3: a 429 pause is clamped to Bitbucket's 3600 s window, and belongs to the credential fingerprint that met it (anonymous separately). A different credential is not held by the old pause.
  • L4: the credential goes to api.bitbucket.org, and also to bitbucket.org for attachments named in the conversation (the media read). Redirects off either origin never carry it.
  • L2: for a fork pull request, files() now asks /merge-base/{source ws/repo}:{head}..{destination}, the revspec form Bitbucket's own comment links use. A same-repository PR keeps /merge-base/{head}..{destination}, which was verified live. The fork form is unverified: the anonymous budget was at 0 when checked (429, x-ratelimit-remaining: 0), so no public fork PR could be read.

Checks at the PR head

  • cargo fmt --all --check: clean

  • cargo clippy --workspace --all-targets --locked -- -D warnings: clean

  • cargo nextest run --workspace --locked --no-fail-fast: 1132 tests run: 1132 passed, 13 skipped (at c42b5ceb)

  • cargo machete: no unused dependencies

  • iOS, Android and Web run in CI only. Dark theme and narrow desktop were not inspected; the phone geometry was.

  • This change alters the wire protocol, so the next release needs a PROTOCOL_VERSION bump: a note was added under "Unreleased" above the constant in crates/protocol/src/lib.rs (host kind bitbucket, credential sources saved_basic/saved_bearer, SetHostToken.email).

Follow-ups (not in this PR)

  • L1: before its first files read, the Files tab label shows "Files 0", because a Bitbucket summary has no file count. Making changed_files optional across hosts will be filed by the lead.
  • L5: a host-owned token_url and the Unverified notice for access tokens; the Clear/Set button order; the "Not connected" status line duplicated by the notice.
  • A labels capability, so the Labels row is absent on hosts without labels (Bitbucket shows "No labels" today).
  • Live verification of every write (and of the fork merge-base form) once a token exists.

Merge Danger

Door: two-way
Blast Radius: additive
Bitbucket is a new kind behind the existing dispatcher. GitHub, Forgejo and GitLab change only where Forgejo and GitLab now call the shared checkout, anchoring and revision helpers instead of their own identical copies. The wire gains a kind, two credential sources and an optional email on SetHostToken (noted for the next release). Secrets gain the @bitbucket and @bitbucket-email groups, which older builds keep as unknown profile entries.

Part of #643

…ions are forge's own

Forgejo and GitLab each kept the same remote resolution, the same commentable
and reanchor reads and Forgejo the diff's index-line revisions; they move to
forge::{checkout, anchors, viewed} so a third host shares them.
…ail or alone

HostKind::Bitbucket is Bitbucket Cloud: bitbucket.org alone, never a host whose
name has a bitbucket label (Data Center), with no CLI, no stacks and "#"
numbers. A saved Bitbucket token is an API token with its Atlassian email
(Basic) or an access token (Bearer); SetHostToken carries the optional email and
saving one method clears the other. The linking text skips a host without a CLI,
so the GitHub-only text is unchanged.
Reads: the pull request with its head's build statuses, comments as threads by
parent with resolution and edits, the diff and diffstat by Bitbucket's own next
page, file text at a commit, conflicts through the shared conflict rule, default
reviewers and workspace members, activity for the watch, viewed marks kept by
Tcode. Writes: comment, reply, resolve, edit, title and description, reviewers
written whole and checked against Bitbucket's answer, draft, decline, merge,
and a review replayed as line comments, a summary and the vote. The credential
goes to api.bitbucket.org only, redirects are followed on its origin only, and
a 429 pauses until X-RateLimit-Reset.
…se it cannot undo

Add host offers Bitbucket with bitbucket.org fixed; the Bitbucket row chooses an
API token with the Atlassian email or an access token; the close confirmation
says the host cannot reopen it where reopen is absent.
…around /conflicts

The undocumented /conflicts endpoint leaves mergeability unknown when it stops
answering. While no credential reads, summaries and branch lookups are kept ten
minutes. A rate-limit pause lasts at most Bitbucket's hourly window and belongs
to the credential that met it, and a new credential drops what was kept. A
fork's merge base names the fork's head in its own repository.
@Tryanks
Tryanks marked this pull request as ready for review October 11, 2026 04:56
@Tryanks
Tryanks enabled auto-merge (squash) October 11, 2026 04:56
@Tryanks
Tryanks merged commit 897ee9f into main Oct 11, 2026
7 checks passed
@Tryanks
Tryanks deleted the feat/bitbucket-host branch October 11, 2026 05:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant