Skip to content

Add Bitbucket Server to GitHub Enterprise migration tool - #1

Open
eddymhawejj wants to merge 74 commits into
mainfrom
claude/bitbucket-github-migration-jGcy6
Open

Add Bitbucket Server to GitHub Enterprise migration tool#1
eddymhawejj wants to merge 74 commits into
mainfrom
claude/bitbucket-github-migration-jGcy6

Conversation

@eddymhawejj

Copy link
Copy Markdown
Owner

Summary\n\n- Bulk migration (bb2gh migrate): Clones all repos from Bitbucket Server via SSH, creates them on GitHub Enterprise, and pushes --mirror (all branches, tags, full history)\n- Continuous sync (bb2gh sync): Fetches from Bitbucket and pushes to GitHub every 60 seconds, enabling a soft cutover where teams can still merge PRs in Bitbucket during transition\n- PR migration (bb2gh migrate-prs): Recreates open Bitbucket PRs as real GitHub PRs with title, description, comments, and reviewer assignments\n\nIncludes Bitbucket Server REST API client with pagination, GitHub Enterprise client (PyGithub), JSON state tracking, Docker/docker-compose support, and 15 passing unit tests.\n\n## Test plan\n\n- [x] All 15 unit tests pass (python -m pytest tests/ -v)\n- [ ] Integration test: configure against a real Bitbucket Server instance and run bb2gh migrate\n- [ ] Verify continuous sync picks up new commits within 60s\n- [ ] Test PR migration with --dry-run first, then live\n- [ ] Test Docker deployment with docker compose up -d sync\n\nhttps://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP

claude added 2 commits March 24, 2026 05:23
Python CLI (bb2gh) with three subcommands:
- migrate: bulk clone repos via SSH, push --mirror to GitHub
- sync: continuous fetch+push every 60s for smooth transition
- migrate-prs: recreate open Bitbucket PRs on GitHub with comments

Includes Bitbucket Server REST API client, GitHub Enterprise client
(PyGithub), state tracking, Docker support, and 15 passing tests.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 180c0b3495

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bb2gh/cli.py Outdated
and reviewer assignments.
"""
config = ctx.obj["config"]
migrated, skipped, failed = migrate_pull_requests(config, dry_run=dry_run)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard CLI against None result from PR migration

migrate_pull_requests returns None when no migrated repos exist (early return in pr_migrator.py), but this call site always unpacks three values. On a fresh environment where bb2gh migrate has not run yet, bb2gh migrate-prs will raise TypeError instead of exiting cleanly, so this path needs a default (0, 0, 0) result or a guard before unpacking.

Useful? React with 👍 / 👎.

Comment thread bb2gh/github_client.py Outdated
def get_clone_url(self, repo_name):
"""Get the HTTPS clone URL for a repo."""
repo = self.org.get_repo(repo_name)
return repo.clone_url

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return an authenticated URL for git mirror pushes

This returns repo.clone_url (plain HTTPS), and the migrator uses that remote for git push --mirror; unless the runtime already has an external credential helper configured, pushes to GitHub Enterprise will fail even though gh_token exists in config. To make migration reliable in unattended runs (including Docker), this should use an authenticated transport (for example SSH URL or token-backed HTTPS).

Useful? React with 👍 / 👎.

Comment thread docker-compose.yml
# One-time bulk migration
migrate:
build: .
command: ["migrate", "--config", "/config/config.yaml"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move --config before subcommands in compose commands

With ENTRYPOINT ["bb2gh"], this becomes bb2gh migrate --config /config/config.yaml, but --config is declared on the top-level Click group (cli.py) and must be parsed before the subcommand. In this order Click raises No such option: --config, so the migrate service command (and the same pattern in sync/migrate-prs) fails at startup.

Useful? React with 👍 / 👎.

Comment thread bb2gh/pr_migrator.py Outdated
repo_name=repo_slug,
title=title,
body=body,
head=head_branch,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Qualify PR head with source repo for forked pull requests

GitHub create_pull needs source-repo context for cross-repo/fork PRs, but this passes only the branch name as head. For Bitbucket PRs whose fromRef points to a different repository, migration will fail (or target the wrong branch if names collide), so the source repository owner/name must be incorporated when building head.

Useful? React with 👍 / 👎.

claude added 27 commits March 28, 2026 11:30
Support mapping Bitbucket projects to different GitHub orgs and
customizing repo names via templates and per-repo overrides in config.

- Config.resolve_target() resolves (bb_project, bb_slug) -> (gh_org, gh_repo_name)
- GithubClient now supports multi-org operations via per-method org_name param
- State tracks the resolved GitHub org/repo for each migrated repo
- PR migrator uses stored mapping with fallback to config resolution

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Covers prerequisites, Bitbucket access requirements, SSH setup,
config with repo mapping examples, migration workflow, Docker usage,
CLI reference, full configuration table, and troubleshooting.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Lets users migrate only a subset of repos from a Bitbucket project:
- include_repos acts as an allowlist (exclusive)
- exclude_repos acts as a denylist
- include wins when both are set

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Fixes SSL handshake failure when Bitbucket Server uses an internal CA.
Set bitbucket.verify_ssl: false in config to disable verification.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
- Inject PAT into HTTPS clone URL so git push doesn't prompt for
  credentials interactively
- Suppress urllib3 InsecureRequestWarning when verify_ssl is false

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
… sync

Rewrites .gitmodules on all branches using git plumbing (hash-object,
mktree, commit-tree) in bare repos. Runs between fetch and push so
GitHub always has the correct URLs. Uses fixed timestamps for
deterministic commits to avoid unnecessary force-pushes.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Reads the symbolic-ref HEAD from the bare clone (Bitbucket's default
branch, typically 'master') and sets it via the GitHub API after
mirror push, preventing GitHub from picking a random default.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
SSH submodule URLs now remap to git@host:org/repo.git format instead
of HTTPS. HTTP submodule URLs continue to remap to HTTPS.
New config: github.ssh_host sets the GitHub SSH hostname.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Repos with files >100MB (GitHub's limit) are automatically handled
by running git lfs migrate import --everything --above=<threshold>
before push. LFS objects are pushed separately after the mirror push.
Opt-in via lfs.enabled config. Works for both migrate and sync.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
git lfs migrate import requires a working tree. Now clones the bare
repo to a temp directory, runs LFS migration there, then fetches the
rewritten refs and LFS objects back into the bare repo.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
GitHub rejects descriptions containing control characters (newlines,
tabs, etc.) that Bitbucket allows. Strip them and cap at 350 chars.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
LFS migration now creates local branches for all remote branches in
the temp working copy before running git lfs migrate, ensuring all
branches are rewritten. Also fetches rewritten tags. Error logs now
redact tokens from URLs to prevent credential leaks.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Branch-already-exists and remote-not-found errors are expected during
normal operation. Use quiet=True to prevent logging them as ERROR.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
…ailure

- Match BB URLs by hostname (supports FQDN variants via ssh_hostnames config)
- If ANY Bitbucket URL cannot be resolved, skip the entire .gitmodules
  rewrite to avoid mixing old and new URLs
- Already-GitHub and external URLs are ignored (not counted as failures)
- URLs already pointing to GitHub are left as-is

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Scans bare clones for repos that have .gitmodules, removes them from
state.json so the next migrate run re-processes them with the updated
submodule URL remapping. Supports --dry-run to preview.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
_migrate_lfs now returns whether it actually converted any files.
git lfs push --all is only run when there are LFS objects to push,
preventing hour-long hangs scanning repos with no large files.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Scans bare clones for blobs above a size threshold (default 100mb),
resets those repos in state.json so the next migrate run re-processes
them with the current LFS config. Supports --above and --dry-run.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Previous approach scanned for large blobs, but after LFS migration
the blobs are already replaced with pointers. Now checks for lfs/
directory and .gitattributes with filter=lfs patterns instead.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Repos that already use LFS on Bitbucket have pointers referencing
BB's LFS server. The temp clone for LFS migration would fail trying
to download those objects. Set GIT_LFS_SKIP_SMUDGE=1 to skip.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Allows resetting specific projects, individual repos, or everything
in state.json so they get re-migrated. Useful when changing org
mappings or fixing specific repos.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
git clone --bare does not create a fetch refspec, so git fetch origin
was a no-op — it never reset local refs to Bitbucket's originals.
Now explicitly fetches +refs/heads/*:refs/heads/* and +refs/tags/*:refs/tags/*
so re-runs properly overwrite previously remapped refs.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
git lfs migrate import may fail on the post-rewrite checkout when
HEAD points to a non-existent branch. The rewrite itself completes
successfully. Detect this case and continue instead of failing.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
os.listdir on lfs/objects/ was matching empty subdirectories created
by git lfs install, causing git lfs push --all to run on repos with
no real LFS objects. Now walks the directory tree to check for actual
files before reporting LFS objects exist.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
When github.ssh_host is set, the push remote uses
git@host:org/repo.git instead of HTTPS. This avoids HTTP 500
timeouts on large repos and keeps protocol consistent with origin.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Change from SCP-style (git@host:org/repo.git) to standard SSH URL
format (ssh://git@host/org/repo.git) for compatibility.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
GHE may use a non-standard SSH user (e.g. gatehousesatcom@ instead
of git@). New config github.ssh_url allows specifying the full SSH
prefix (e.g. ssh://gatehousesatcom@host) for push remotes and
submodule URL remapping.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Scan the bare repo's git objects for blobs above the threshold before
cloning to a temp dir and running git lfs migrate import. Repos with
no large files skip the entire LFS step (clone + rewrite), saving
minutes on large repos like wireshark (81k commits, 10 min wasted).

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
claude added 30 commits April 20, 2026 09:51
Specified repos use --shallow-since on clone and fetch, keeping only
recent commits. HEAD commit hashes are preserved (no history rewrite).

Config:
  trim_history:
    UPSTREAM/linux: "2y"
    UPSTREAM/git: "1y"

Supports Ny (years), Nm (months), Nd (days).

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
git push --mirror fails on shallow clones because the pack references
parent objects at the shallow boundary that don't exist. Trimmed repos
now push branches (--all --force) and tags separately. Tags referencing
pruned history are skipped with a warning.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Pushing all branches at once creates a pack that exceeds GitHub's
2GB limit for large repos like Linux kernel forks. Now pushes each
branch individually so each pack is small enough.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
…rimming

Shallow clones can't be pushed to GitHub (missing parent objects).
Now uses git replace --graft to make cutoff commits into true root
commits, then filter-branch to rewrite history permanently. Pushes
cleanly with --mirror. Commit hashes change but content is preserved.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
git push --mirror creates a single pack for all refs. For Linux
kernel forks with 100+ branches this exceeds GitHub's 2GB limit.
Now detects the error and automatically retries by pushing each
branch and tag individually.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Repos listed in push_by_branch go straight to branch-by-branch push,
skipping the --mirror attempt that would fail with the 2GB pack limit.
Saves time and bandwidth. Auto-fallback still works for unlisted repos.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Respects migrate_delay_seconds between each branch/tag push to
avoid SSH rate limiting from GitHub.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Repos listed in push_by_branch now use branch-by-branch push in
sync cycles too, with migrate_delay between each push.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Tags are lightweight refs — pushing them all at once doesn't hit
the 2GB pack limit. Falls back to individual push if batch fails.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Allows individual repos to target a different GitHub org than their
project default. Example: repos.thuraya-autotest.github_org: "other-org"

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
New commands to prepare workspaces for Jenkins-to-GitHub-Actions
conversion by AI agents:

- prepare-jenkins: Creates sparse-checkout workspaces with only
  Jenkinsfiles and dependencies, creates migration branch on GitHub
- jenkins-manifest: Generates YAML manifest listing repos with
  Jenkinsfiles, clone URLs, and file paths for remote AI agents

Features:
- Auto-discovers Jenkinsfiles (any case, any location)
- Finds dependencies: vars/, src/, resources/, colocated .groovy
- Parses load/readFile references in Jenkinsfiles
- Per-repo manifest + global manifest for remote agent use
- State tracking to avoid re-preparing repos
- Dry-run support

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Repos with no HEAD (empty/broken) now skip with a warning instead
of crashing.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
1. project_aliases config maps old Bitbucket project keys to current
   keys, so .gitmodules with outdated URLs get remapped correctly.

2. sync.protected_branches prevents sync from deleting specified
   branches on GitHub (e.g., ci/github-actions-migration). When set,
   sync uses --all --force instead of --mirror, then prunes only
   unprotected branches that don't exist in Bitbucket.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
1. Auto-resolve renamed project keys via Bitbucket API — when a
   .gitmodules URL uses an old project key, the tool queries
   GET /rest/api/1.0/projects/{old_key} to get the current key.
   Results are cached per remap cycle. Manual project_aliases config
   still works as a fallback.

2. sync.protected_branches config prevents sync from deleting
   specified branches on GitHub (e.g., ci/github-actions-migration).
   When set, sync uses --all --force + selective pruning instead of
   --mirror.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Uses fnmatch for pattern matching, so ci/* matches ci/github-actions,
ci/migration, etc. Exact names still work (e.g., main).

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
The previous resolver only checked project renames. Now queries
GET /projects/{key}/repos/{slug} which returns the repo's CURRENT
project key — handling repos that were moved from one project to
another (e.g., 3rdparty_LIBCodegenix moved from SYS_BGANRAN to
SYS_COM). Results are cached per remap cycle.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Previously the API resolver was a fallback after config lookup
succeeded with the old project key. Now resolves the actual repo
location first, so moved repos (e.g., SYS_BGANRAN -> SYS_COM)
get the correct GitHub org mapping.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Previously the cache was recreated per branch, causing repeated API
calls for the same project/slug combos. Now a single cache is shared
across all branches within a repo remap — so if a repo has 700
branches with the same submodules, each unique combo is resolved once.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Fixed timestamp was deterministic but showed as 27 years old in
git log. Now uses the current date/time, fixed for the entire run
so all branches in one repo get the same timestamp.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Hidden refs (refs/pull/*, refs/merge-request/*) were included in the
snapshot but cleaned before push. Next cycle's show-ref didn't include
them, causing a mismatch every time. Now cleans before snapshotting.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
show-ref includes refs/remotes/github/* which change every push
cycle (remap commits have different hashes due to timestamps). Now
uses show-ref --heads --tags to only compare Bitbucket's branches
and tags, ignoring internal remote tracking refs.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
The LFS push had no timeout and would hang indefinitely scanning
large repos. Now respects sync_lfs_timeout_seconds for both the
migration and the push steps.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
--all scans every branch and tag history for LFS pointers, taking
hours on repos with many refs even with only a few LFS files. Now
enumerates actual LFS object files in lfs/objects/ and pushes each
by OID directly — no scanning, instant for a handful of objects.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
--include-closed flag migrates merged/declined PRs by:
1. Finding the source branch's last commit SHA from Bitbucket PR data
2. If the commit exists in the repo, recreating the branch on GitHub
3. Creating a real PR with full discussion, then closing it
4. Fallback: if commit doesn't exist, creates a GitHub Issue instead

Also adds --repo filter to migrate-prs command.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
When fromRef.latestCommit is garbage-collected (common after squash
merges), the migrator now falls back to:
1. properties.mergeCommit from the Bitbucket PR object
2. The MERGED activity's commit field

For squash/merge commits (which are already on the target branch),
creates a temp base branch at the commit's parent so the GitHub PR
shows the actual squash diff rather than "nothing to compare".

Also adds BitbucketClient.get_pull_request() and get_merge_commit()
methods, plus tests for the SHA resolution cascade.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
--closed-only skips open PRs entirely (for users who already migrated
open PRs and now want to backfill merged/declined ones).

Throttler class enforces:
- Minimum spacing between GitHub API calls (--api-delay, default 0.5s)
- Pause between full PR migrations (--pr-delay, default 3s)
- Automatic retry with exponential backoff on 429 / secondary rate
  limits, honoring Retry-After header when present

Defaults are configurable via a new pr_migration section in config.yaml:
  pr_migration:
    api_delay_seconds: 0.5
    pr_delay_seconds: 3.0
    max_retries: 5

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
Simpler and more useful than the branch-recreation approach: every
closed PR (merged or declined) becomes a closed GitHub Issue with
all its comments preserved. Labeled `migrated-pr` + `merged`/`declined`
for filtering. Everything is searchable in one place — the issue
tracker — instead of split between PRs and issues.

Removes:
- Branch recreation code (git subprocess, temp branches, push logic)
- SHA resolution cascade (_resolve_head_sha, get_merge_commit)
- BitbucketClient.get_pull_request, get_merge_commit
- All dependency on the bare clone for closed PR migration

Also fixes chronological comment ordering — comments were sorted by
activity.createdDate but Bitbucket puts it on comment.createdDate.

https://claude.ai/code/session_01HK99UAxTgAiWTNYAfSoVvP
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.

2 participants