From be22089eb1fbb860f5350d83b4102367bd12caee Mon Sep 17 00:00:00 2001 From: Steve Hoeksema Date: Fri, 29 May 2026 21:16:26 +1200 Subject: [PATCH] bitbucket: require BITBUCKET_WORKSPACES, prefer BITBUCKET_EMAIL Atlassian removed the cross-workspace listing endpoints (/2.0/workspaces and /2.0/user/permissions/workspaces) on April 14, 2026 under CHANGE-2770 / CHANGE-3022. The Bitbucket backup path has been failing with HTTP 410 Gone since then, because getBitbucketRepositories() called client.Workspaces.List() to discover which workspaces to iterate. There is no supported replacement that enumerates a user's workspaces; the caller must now know each workspace slug out-of-band. Require the user to provide them via a new BITBUCKET_WORKSPACES env var (comma-separated). Repository listing per workspace still works via the unchanged /2.0/repositories/{workspace} endpoint, so the rest of the flow is unaffected. Also rename the credential username variable to make it clearer that for Atlassian API tokens this must be the account email, not the legacy Bitbucket username. Accept BITBUCKET_EMAIL in preference to BITBUCKET_USERNAME; keep BITBUCKET_USERNAME as a fallback so existing setups using app passwords (still supported until July 28, 2026) keep working. Fixes https://github.com/amitsaha/gitbackup/issues/223 --- README.md | 12 ++++++++---- bitbucket.go | 25 +++++++++++++++++++------ client.go | 15 +++++++++++---- repositories_test.go | 16 ++++++++++------ 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7e11721..587495c 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,11 @@ SSH cloning on Windows requires your SSH key to be accessible from WSL2 or Docke ``gitbackup`` requires a [GitHub API access token](https://github.com/blog/1509-personal-api-tokens) for backing up GitHub repositories, a [GitLab personal access token](https://gitlab.com/-/user_settings/personal_access_tokens) -for GitLab repositories, a username and [API token](https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/) (or [app password](https://bitbucket.org/account/settings/app-passwords/)) for +for GitLab repositories, an Atlassian account email plus an [API token](https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/) (or [app password](https://bitbucket.org/account/settings/app-passwords/)) for Bitbucket repositories, or a [Forgejo access token][https://docs.codeberg.org/advanced/access-token/] for Forgejo. You can supply the tokens to ``gitbackup`` using ``GITHUB_TOKEN``, ``GITLAB_TOKEN``, or ``FORGEJO_TOKEN`` environment -variables respectively, and the Bitbucket credentials with ``BITBUCKET_USERNAME`` and either ``BITBUCKET_TOKEN`` or ``BITBUCKET_PASSWORD``. +variables respectively, and the Bitbucket credentials with ``BITBUCKET_EMAIL`` (or ``BITBUCKET_USERNAME`` for legacy app-password setups) and either ``BITBUCKET_TOKEN`` or ``BITBUCKET_PASSWORD``. Bitbucket additionally requires ``BITBUCKET_WORKSPACES`` (comma-separated workspace slugs) because Atlassian removed the cross-workspace listing APIs on April 14, 2026. ### GitHub Specific oAuth App Flow @@ -435,18 +435,22 @@ $ GITLAB_TOKEN=secret$token gitbackup -service gitlab -githost.url https://git.y #### Backing up your Bitbucket repositories +Atlassian removed the cross-workspace listing endpoints on April 14, 2026. +There is no supported way to enumerate the workspaces a user belongs to programmatically, so you must list them +explicitly via ``BITBUCKET_WORKSPACES``. + To backup all your Bitbucket repositories to the default backup directory (``$HOME/.gitbackup/``): Using an API token (recommended): ```lang=bash -$ BITBUCKET_USERNAME= BITBUCKET_TOKEN=token gitbackup -service bitbucket +$ BITBUCKET_EMAIL= BITBUCKET_TOKEN=token BITBUCKET_WORKSPACES=ws1,ws2 gitbackup -service bitbucket ``` Using an app password (deprecated, disabled after June 9, 2026): ```lang=bash -$ BITBUCKET_USERNAME=username BITBUCKET_PASSWORD=password gitbackup -service bitbucket +$ BITBUCKET_USERNAME=username BITBUCKET_PASSWORD=password BITBUCKET_WORKSPACES=ws1,ws2 gitbackup -service bitbucket ``` #### Backing up your Forgejo repositories diff --git a/bitbucket.go b/bitbucket.go index 089163e..25b919a 100644 --- a/bitbucket.go +++ b/bitbucket.go @@ -1,6 +1,8 @@ package main import ( + "log" + "os" "strings" bitbucket "github.com/ktrysmt/go-bitbucket" @@ -11,15 +13,26 @@ func getBitbucketRepositories( ignoreFork bool, ) ([]*Repository, error) { + // As of April 14, 2026 Atlassian removed the cross-workspace listing + // endpoints (/2.0/workspaces and /2.0/user/permissions/workspaces) under + // changelog entries CHANGE-2770 / CHANGE-3022. There is no supported way + // to enumerate the workspaces a user belongs to programmatically. The + // caller must supply the workspace slugs via BITBUCKET_WORKSPACES + // (comma-separated). + workspacesEnv := os.Getenv("BITBUCKET_WORKSPACES") + if workspacesEnv == "" { + log.Fatal("BITBUCKET_WORKSPACES environment variable not set (comma-separated workspace slugs)") + } + var repositories []*Repository - resp, err := client.Workspaces.List() - if err != nil { - return nil, err - } + for _, slug := range strings.Split(workspacesEnv, ",") { + slug = strings.TrimSpace(slug) + if slug == "" { + continue + } - for _, workspace := range resp.Workspaces { - options := &bitbucket.RepositoriesOptions{Owner: workspace.Slug} + options := &bitbucket.RepositoriesOptions{Owner: slug} resp, err := client.Repositories.ListForAccount(options) if err != nil { diff --git a/client.go b/client.go index 810f9df..5229a36 100644 --- a/client.go +++ b/client.go @@ -171,9 +171,16 @@ func newGitLabClient(gitHostURLParsed *url.URL) *gitlab.Client { // newBitbucketClient creates a new Bitbucket client func newBitbucketClient(gitHostURLParsed *url.URL) *bitbucket.Client { - bitbucketUsername := os.Getenv("BITBUCKET_USERNAME") - if bitbucketUsername == "" { - log.Fatal("BITBUCKET_USERNAME environment variable not set") + // Atlassian API tokens are scoped to the Atlassian account, which is + // identified by an email address rather than a Bitbucket username. + // Prefer BITBUCKET_EMAIL for clarity and fall back to BITBUCKET_USERNAME + // for backwards compatibility with legacy app-password setups. + bitbucketEmailOrUsername := os.Getenv("BITBUCKET_EMAIL") + if bitbucketEmailOrUsername == "" { + bitbucketEmailOrUsername = os.Getenv("BITBUCKET_USERNAME") + } + if bitbucketEmailOrUsername == "" { + log.Fatal("BITBUCKET_EMAIL or BITBUCKET_USERNAME environment variable not set") } bitbucketPasswordOrToken := os.Getenv("BITBUCKET_TOKEN") @@ -185,7 +192,7 @@ func newBitbucketClient(gitHostURLParsed *url.URL) *bitbucket.Client { } gitHostToken = bitbucketPasswordOrToken - client, err := bitbucket.NewBasicAuth(bitbucketUsername, bitbucketPasswordOrToken) + client, err := bitbucket.NewBasicAuth(bitbucketEmailOrUsername, bitbucketPasswordOrToken) if err != nil { log.Fatalf("Error creating Bitbucket client: %v", err) } diff --git a/repositories_test.go b/repositories_test.go index b3a49c7..66a35ba 100644 --- a/repositories_test.go +++ b/repositories_test.go @@ -30,6 +30,7 @@ func setupRepositoryTests() { os.Setenv("GITLAB_TOKEN", "$$$randome") os.Setenv("BITBUCKET_USERNAME", "bbuser") os.Setenv("BITBUCKET_PASSWORD", "$$$randomp") + os.Setenv("BITBUCKET_WORKSPACES", "ws1,ws2") os.Setenv("FORGEJO_TOKEN", "$$$randome") // test server mux = http.NewServeMux() @@ -237,12 +238,11 @@ func TestGetBitbucketRepositories(t *testing.T) { setupRepositoryTests() defer teardownRepositoryTests() - mux.HandleFunc("/workspaces", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"pagelen": 10, "page": 1, "size": 1, "values": [{"slug": "abc"}]}`) + mux.HandleFunc("/repositories/ws1", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"pagelen": 10, "page": 1, "size": 1, "values": [{"full_name":"ws1/repo1", "slug":"repo1", "is_private":true, "links":{"clone":[{"name":"https", "href":"https://bbuser@bitbucket.org/ws1/repo1.git"}, {"name":"ssh", "href":"git@bitbucket.org:ws1/repo1.git"}]}}]}`) }) - - mux.HandleFunc("/repositories/abc", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"pagelen": 10, "page": 1, "size": 1, "values": [{"full_name":"abc/def", "slug":"def", "is_private":true, "links":{"clone":[{"name":"https", "href":"https://bbuser@bitbucket.org/abc/def.git"}, {"name":"ssh", "href":"git@bitbucket.org:abc/def.git"}]}}]}`) + mux.HandleFunc("/repositories/ws2", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"pagelen": 10, "page": 1, "size": 1, "values": [{"full_name":"ws2/repo2", "slug":"repo2", "is_private":true, "links":{"clone":[{"name":"https", "href":"https://bbuser@bitbucket.org/ws2/repo2.git"}, {"name":"ssh", "href":"git@bitbucket.org:ws2/repo2.git"}]}}]}`) }) repos, err := getRepositories(BitbucketClient, "bitbucket", "", []string{}, "", "", false, "") @@ -250,7 +250,11 @@ func TestGetBitbucketRepositories(t *testing.T) { t.Fatalf("%v", err) } var expected []*Repository - expected = append(expected, &Repository{Namespace: "abc", CloneURL: "git@bitbucket.org:abc/def.git", Name: "def", Private: true}) + expected = append( + expected, + &Repository{Namespace: "ws1", CloneURL: "git@bitbucket.org:ws1/repo1.git", Name: "repo1", Private: true}, + &Repository{Namespace: "ws2", CloneURL: "git@bitbucket.org:ws2/repo2.git", Name: "repo2", Private: true}, + ) if !reflect.DeepEqual(repos, expected) { for i := 0; i < len(repos); i++ { t.Errorf("Expected %+v, Got %+v", expected[i], repos[i])