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])