Skip to content

Commit 77a75d5

Browse files
committed
(to be squashed) add bitbucketcloud & bitbucketserver
Signed-off-by: Aryan Goyal <mail@ary82.dev>
1 parent 3e7d091 commit 77a75d5

7 files changed

Lines changed: 780 additions & 19 deletions

File tree

pkg/internal/scmclient/bitbucketcloud.go

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,38 +18,130 @@ package scmclient
1818

1919
import (
2020
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"io"
2125
"net/http"
26+
"net/url"
2227
"strings"
2328
)
2429

2530
const defaultBitbucketCloudBaseURL = "https://api.bitbucket.org"
2631

2732
type bitbucketCloudClient struct {
28-
baseURL string
29-
token string
30-
httpClient *http.Client
33+
baseURL string
34+
username string
35+
password string
36+
useBasicAuth bool
37+
httpClient *http.Client
3138
}
3239

40+
// newBitbucketCloudClient accepts token in two formats:
41+
// - "username:api_token": uses HTTP Basic auth. This is a workaround
42+
// for issues (#7189, #9484) where the username was never passed to the
43+
// factory, causing API calls to silently fail for private repositories.
44+
// - bare token: uses Bearer auth for repository/workspace access tokens,
45+
// which do not require a username (supersedes the approach in PR #9543).
3346
func newBitbucketCloudClient(serverURL, token string) SCMClient {
3447
base := serverURL
3548
if base == "" {
3649
base = defaultBitbucketCloudBaseURL
3750
}
51+
username, password := splitUsernamePassword(token)
3852
return &bitbucketCloudClient{
39-
baseURL: strings.TrimRight(base, "/"),
40-
token: token,
41-
httpClient: &http.Client{},
53+
baseURL: strings.TrimRight(base, "/"),
54+
username: username,
55+
password: password,
56+
useBasicAuth: strings.Contains(token, ":"),
57+
httpClient: &http.Client{},
4258
}
4359
}
4460

61+
func (c *bitbucketCloudClient) newRequest(ctx context.Context, rawURL string) (*http.Request, error) {
62+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
63+
if err != nil {
64+
return nil, err
65+
}
66+
if c.useBasicAuth {
67+
req.SetBasicAuth(c.username, c.password)
68+
} else {
69+
req.Header.Set("Authorization", "Bearer "+c.password)
70+
}
71+
return req, nil
72+
}
73+
74+
// API docs: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/#api-repositories-workspace-repo-slug-src-commit-path-get
4575
func (c *bitbucketCloudClient) GetFileContent(ctx context.Context, org, repo, path, ref string) ([]byte, error) {
46-
panic("unimplemented")
76+
rawURL := fmt.Sprintf("%s/2.0/repositories/%s/%s/src/%s/%s",
77+
c.baseURL, org, repo, url.PathEscape(ref), url.PathEscape(path))
78+
req, err := c.newRequest(ctx, rawURL)
79+
if err != nil {
80+
return nil, fmt.Errorf("bitbucketcloud: GetFileContent: %w", err)
81+
}
82+
resp, err := c.httpClient.Do(req)
83+
if err != nil {
84+
return nil, fmt.Errorf("bitbucketcloud: GetFileContent: request failed: %w", err)
85+
}
86+
defer resp.Body.Close()
87+
if resp.StatusCode != http.StatusOK {
88+
body, _ := io.ReadAll(resp.Body)
89+
return nil, fmt.Errorf("bitbucketcloud: GetFileContent: unexpected status %d: %s", resp.StatusCode, body)
90+
}
91+
return io.ReadAll(resp.Body)
4792
}
4893

94+
// API docs: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-commit-commit-get
4995
func (c *bitbucketCloudClient) GetCommitSHA(ctx context.Context, org, repo, ref string) (string, error) {
50-
panic("unimplemented")
96+
rawURL := fmt.Sprintf("%s/2.0/repositories/%s/%s/commit/%s",
97+
c.baseURL, org, repo, url.PathEscape(ref))
98+
req, err := c.newRequest(ctx, rawURL)
99+
if err != nil {
100+
return "", fmt.Errorf("bitbucketcloud: GetCommitSHA: %w", err)
101+
}
102+
body, err := doRequest(ctx, c.httpClient, req)
103+
if err != nil {
104+
return "", fmt.Errorf("bitbucketcloud: GetCommitSHA: %w", err)
105+
}
106+
var resp struct {
107+
Hash string `json:"hash"`
108+
}
109+
if err := json.Unmarshal(body, &resp); err != nil {
110+
return "", fmt.Errorf("bitbucketcloud: GetCommitSHA: failed to parse response: %w", err)
111+
}
112+
if resp.Hash == "" {
113+
return "", errors.New("bitbucketcloud: GetCommitSHA: empty sha in response")
114+
}
115+
return resp.Hash, nil
51116
}
52117

118+
// API docs: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-get
53119
func (c *bitbucketCloudClient) GetCloneURL(ctx context.Context, org, repo string) (string, error) {
54-
panic("unimplemented")
120+
rawURL := fmt.Sprintf("%s/2.0/repositories/%s/%s",
121+
c.baseURL, org, repo)
122+
req, err := c.newRequest(ctx, rawURL)
123+
if err != nil {
124+
return "", fmt.Errorf("bitbucketcloud: GetCloneURL: %w", err)
125+
}
126+
body, err := doRequest(ctx, c.httpClient, req)
127+
if err != nil {
128+
return "", fmt.Errorf("bitbucketcloud: GetCloneURL: %w", err)
129+
}
130+
var resp struct {
131+
Links struct {
132+
Clone []struct {
133+
Name string `json:"name"`
134+
Href string `json:"href"`
135+
} `json:"clone"`
136+
} `json:"links"`
137+
}
138+
if err := json.Unmarshal(body, &resp); err != nil {
139+
return "", fmt.Errorf("bitbucketcloud: GetCloneURL: failed to parse response: %w", err)
140+
}
141+
for _, link := range resp.Links.Clone {
142+
if link.Name == "https" {
143+
return link.Href, nil
144+
}
145+
}
146+
return "", errors.New("bitbucketcloud: GetCloneURL: no https clone URL found")
55147
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package scmclient
2+
3+
import (
4+
"context"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestBitbucketCloud_GetFileContent_BasicAuth(t *testing.T) {
13+
fileContent := []byte("apiVersion: tekton.dev/v1\nkind: Task\n")
14+
15+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
if r.URL.Path != "/2.0/repositories/test_org/test_repo/src/main/tasks/build.yaml" {
17+
t.Errorf("unexpected path: %s", r.URL.Path)
18+
}
19+
// Basic auth — username:api_token format
20+
username, password, ok := r.BasicAuth()
21+
if !ok {
22+
t.Error("expected Basic auth, got none")
23+
}
24+
if username != "test_user" {
25+
t.Errorf("unexpected username: %s", username)
26+
}
27+
if password != "test_apitoken" {
28+
t.Errorf("unexpected password: %s", password)
29+
}
30+
w.Write(fileContent)
31+
}))
32+
defer server.Close()
33+
34+
client := newBitbucketCloudClient(server.URL, "test_user:test_apitoken")
35+
got, err := client.GetFileContent(context.Background(), "test_org", "test_repo", "tasks/build.yaml", "main")
36+
if err != nil {
37+
t.Fatalf("unexpected error: %v", err)
38+
}
39+
if string(got) != string(fileContent) {
40+
t.Errorf("got %q, want %q", got, fileContent)
41+
}
42+
}
43+
44+
func TestBitbucketCloud_GetFileContent_BearerAuth(t *testing.T) {
45+
fileContent := []byte("apiVersion: tekton.dev/v1\nkind: Task\n")
46+
47+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48+
// Bearer auth — bare token, no username
49+
authHeader := r.Header.Get("Authorization")
50+
if authHeader != "Bearer test_repotoken" {
51+
t.Errorf("unexpected Authorization header: %s", authHeader)
52+
}
53+
_, _, ok := r.BasicAuth()
54+
if ok {
55+
t.Error("expected Bearer auth, got Basic auth")
56+
}
57+
w.Write(fileContent)
58+
}))
59+
defer server.Close()
60+
61+
client := newBitbucketCloudClient(server.URL, "test_repotoken")
62+
got, err := client.GetFileContent(context.Background(), "test_org", "test_repo", "tasks/build.yaml", "main")
63+
if err != nil {
64+
t.Fatalf("unexpected error: %v", err)
65+
}
66+
if string(got) != string(fileContent) {
67+
t.Errorf("got %q, want %q", got, fileContent)
68+
}
69+
}
70+
71+
func TestBitbucketCloud_GetFileContent_Error(t *testing.T) {
72+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73+
w.WriteHeader(http.StatusNotFound)
74+
w.Write([]byte(`{"type": "error", "error": {"message": "Not Found"}}`))
75+
}))
76+
defer server.Close()
77+
78+
client := newBitbucketCloudClient(server.URL, "test_user:test_apitoken")
79+
_, err := client.GetFileContent(context.Background(), "test_org", "test_repo", "missing.yaml", "main")
80+
if err == nil {
81+
t.Fatal("expected error for 404, got nil")
82+
}
83+
}
84+
85+
func TestBitbucketCloud_GetFileContent_RawBytes(t *testing.T) {
86+
// Verify raw bytes are returned directly — no base64 decoding
87+
rawContent := []byte("raw: content\nno: encoding\n")
88+
89+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90+
w.Write(rawContent)
91+
}))
92+
defer server.Close()
93+
94+
client := newBitbucketCloudClient(server.URL, "test_user:test_apitoken")
95+
got, err := client.GetFileContent(context.Background(), "test_org", "test_repo", "file.yaml", "main")
96+
if err != nil {
97+
t.Fatalf("unexpected error: %v", err)
98+
}
99+
if string(got) != string(rawContent) {
100+
t.Errorf("got %q, want %q", got, rawContent)
101+
}
102+
}
103+
104+
func TestBitbucketCloud_GetCommitSHA(t *testing.T) {
105+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106+
if r.URL.Path != "/2.0/repositories/test_org/test_repo/commit/main" {
107+
t.Errorf("unexpected path: %s", r.URL.Path)
108+
}
109+
// Bitbucket Cloud uses "hash" not "sha"
110+
io.WriteString(w, `{"hash": "abc123def456abc123def456abc123def456abc1"}`)
111+
}))
112+
defer server.Close()
113+
114+
client := newBitbucketCloudClient(server.URL, "test_user:test_apitoken")
115+
got, err := client.GetCommitSHA(context.Background(), "test_org", "test_repo", "main")
116+
if err != nil {
117+
t.Fatalf("unexpected error: %v", err)
118+
}
119+
if got != "abc123def456abc123def456abc123def456abc1" {
120+
t.Errorf("got %q, want %q", got, "abc123def456abc123def456abc123def456abc1")
121+
}
122+
}
123+
124+
func TestBitbucketCloud_GetCommitSHA_EmptyHash(t *testing.T) {
125+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126+
io.WriteString(w, `{"hash": ""}`)
127+
}))
128+
defer server.Close()
129+
130+
client := newBitbucketCloudClient(server.URL, "test_user:test_apitoken")
131+
_, err := client.GetCommitSHA(context.Background(), "test_org", "test_repo", "main")
132+
if err == nil {
133+
t.Fatal("expected error for empty hash, got nil")
134+
}
135+
}
136+
137+
func TestBitbucketCloud_GetCloneURL(t *testing.T) {
138+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139+
if r.URL.Path != "/2.0/repositories/test_org/test_repo" {
140+
t.Errorf("unexpected path: %s", r.URL.Path)
141+
}
142+
io.WriteString(w, `{
143+
"links": {
144+
"clone": [
145+
{"name": "ssh", "href": "git@bitbucket.org:test_org/test_repo.git"},
146+
{"name": "https", "href": "https://test_user@bitbucket.org/test_org/test_repo.git"}
147+
]
148+
}
149+
}`)
150+
}))
151+
defer server.Close()
152+
153+
client := newBitbucketCloudClient(server.URL, "test_user:test_apitoken")
154+
got, err := client.GetCloneURL(context.Background(), "test_org", "test_repo")
155+
if err != nil {
156+
t.Fatalf("unexpected error: %v", err)
157+
}
158+
if got != "https://test_user@bitbucket.org/test_org/test_repo.git" {
159+
t.Errorf("got %q, want %q", got, "https://test_user@bitbucket.org/test_org/test_repo.git")
160+
}
161+
}
162+
163+
func TestBitbucketCloud_GetCloneURL_NoHTTPS(t *testing.T) {
164+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165+
// Only SSH link, no HTTPS
166+
io.WriteString(w, `{
167+
"links": {
168+
"clone": [
169+
{"name": "ssh", "href": "git@bitbucket.org:test_org/test_repo.git"}
170+
]
171+
}
172+
}`)
173+
}))
174+
defer server.Close()
175+
176+
client := newBitbucketCloudClient(server.URL, "test_user:test_apitoken")
177+
_, err := client.GetCloneURL(context.Background(), "test_org", "test_repo")
178+
if err == nil {
179+
t.Fatal("expected error when no https clone URL found, got nil")
180+
}
181+
if !strings.Contains(err.Error(), "no https clone URL found") {
182+
t.Errorf("unexpected error message: %v", err)
183+
}
184+
}
185+
186+
func TestBitbucketCloud_splitUsernamePassword(t *testing.T) {
187+
tests := []struct {
188+
token string
189+
wantUsername string
190+
wantPassword string
191+
}{
192+
{"test_user:test_password", "test_user", "test_password"},
193+
{"test_user:pass:with:colons", "test_user", "pass:with:colons"},
194+
{"baretoken", "", "baretoken"},
195+
{":emptyusername", "", "emptyusername"},
196+
}
197+
for _, tt := range tests {
198+
gotUsername, gotPassword := splitUsernamePassword(tt.token)
199+
if gotUsername != tt.wantUsername {
200+
t.Errorf("splitUsernamePassword(%q) username = %q, want %q", tt.token, gotUsername, tt.wantUsername)
201+
}
202+
if gotPassword != tt.wantPassword {
203+
t.Errorf("splitUsernamePassword(%q) password = %q, want %q", tt.token, gotPassword, tt.wantPassword)
204+
}
205+
}
206+
}

0 commit comments

Comments
 (0)