From b8560f026cbc12a8a80a4c159a0e586803ac6829 Mon Sep 17 00:00:00 2001 From: Gunju Kim Date: Wed, 11 Feb 2026 15:24:08 +0000 Subject: [PATCH] Add Bitbucket Data Center pull request source support Add a new BitbucketDataCenterPRs source type to TaskSpawner that discovers pull requests from Bitbucket Data Center repositories. This allows teams using Bitbucket DC to leverage automated task spawning for PR review and processing. Changes: - Add BitbucketDataCenterPRs API type with state filter (OPEN, MERGED, DECLINED, ALL) - Implement BitbucketDataCenterSource using the Bitbucket DC REST API (/rest/api/1.0) with pagination and Bearer token authentication - Parse Bitbucket DC repository URLs (SCM clone, browse, SSH formats) - Wire up deployment builder, spawner, and CLI printer for the new source - Add unit tests, integration tests, and example manifests Fixes #260 Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/taskspawner_types.go | 19 +- api/v1alpha1/workspace_types.go | 5 +- api/v1alpha1/zz_generated.deepcopy.go | 20 ++ cmd/axon-spawner/main.go | 49 ++- cmd/axon-spawner/main_test.go | 64 +++- .../05-taskspawner-bitbucket-dc-prs/README.md | 69 ++++ .../bitbucket-token-secret.yaml | 9 + .../credentials-secret.yaml | 8 + .../taskspawner.yaml | 30 ++ .../workspace.yaml | 14 + install-crd.yaml | 25 +- internal/cli/printer.go | 12 + .../taskspawner_deployment_builder.go | 63 +++- .../taskspawner_deployment_builder_test.go | 130 +++++++ internal/manifests/install-crd.yaml | 25 +- internal/source/bitbucket_dc.go | 225 ++++++++++++ internal/source/bitbucket_dc_test.go | 320 ++++++++++++++++++ test/integration/taskspawner_test.go | 190 +++++++++++ 18 files changed, 1257 insertions(+), 20 deletions(-) create mode 100644 examples/05-taskspawner-bitbucket-dc-prs/README.md create mode 100644 examples/05-taskspawner-bitbucket-dc-prs/bitbucket-token-secret.yaml create mode 100644 examples/05-taskspawner-bitbucket-dc-prs/credentials-secret.yaml create mode 100644 examples/05-taskspawner-bitbucket-dc-prs/taskspawner.yaml create mode 100644 examples/05-taskspawner-bitbucket-dc-prs/workspace.yaml create mode 100644 internal/source/bitbucket_dc.go create mode 100644 internal/source/bitbucket_dc_test.go diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index f63a92d8..287a5ef8 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -23,6 +23,10 @@ type When struct { // +optional GitHubIssues *GitHubIssues `json:"githubIssues,omitempty"` + // BitbucketDataCenterPRs discovers pull requests from a Bitbucket Data Center repository. + // +optional + BitbucketDataCenterPRs *BitbucketDataCenterPRs `json:"bitbucketDataCenterPRs,omitempty"` + // Cron triggers task spawning on a cron schedule. // +optional Cron *Cron `json:"cron,omitempty"` @@ -61,6 +65,18 @@ type GitHubIssues struct { State string `json:"state,omitempty"` } +// BitbucketDataCenterPRs discovers pull requests from a Bitbucket Data Center repository. +// The project key and repository slug are derived from the workspace's repo URL +// specified in taskTemplate.workspaceRef. +// If the workspace has a secretRef, the BITBUCKET_TOKEN key is used for API authentication. +type BitbucketDataCenterPRs struct { + // State filters pull requests by state (OPEN, MERGED, DECLINED, ALL). Defaults to OPEN. + // +kubebuilder:validation:Enum=OPEN;MERGED;DECLINED;ALL + // +kubebuilder:default=OPEN + // +optional + State string `json:"state,omitempty"` +} + // TaskTemplate defines the template for spawned Tasks. type TaskTemplate struct { // Type specifies the agent type (e.g., claude-code). @@ -83,7 +99,7 @@ type TaskTemplate struct { Image string `json:"image,omitempty"` // WorkspaceRef references the Workspace that defines the repository. - // Required when using githubIssues source; optional for other sources. + // Required when using githubIssues or bitbucketDataCenterPRs source; optional for other sources. // When set, spawned Tasks inherit this workspace reference. // +optional WorkspaceRef *WorkspaceReference `json:"workspaceRef,omitempty"` @@ -107,6 +123,7 @@ type TaskTemplate struct { // TaskSpawnerSpec defines the desired state of TaskSpawner. // +kubebuilder:validation:XValidation:rule="!has(self.when.githubIssues) || has(self.taskTemplate.workspaceRef)",message="taskTemplate.workspaceRef is required when using githubIssues source" +// +kubebuilder:validation:XValidation:rule="!has(self.when.bitbucketDataCenterPRs) || has(self.taskTemplate.workspaceRef)",message="taskTemplate.workspaceRef is required when using bitbucketDataCenterPRs source" type TaskSpawnerSpec struct { // When defines the conditions that trigger task spawning. // +kubebuilder:validation:Required diff --git a/api/v1alpha1/workspace_types.go b/api/v1alpha1/workspace_types.go index 79ac18d1..30d67003 100644 --- a/api/v1alpha1/workspace_types.go +++ b/api/v1alpha1/workspace_types.go @@ -16,8 +16,9 @@ type WorkspaceSpec struct { // +optional Ref string `json:"ref,omitempty"` - // SecretRef references a Secret containing a GITHUB_TOKEN key for git - // authentication and GitHub CLI (gh) operations. + // SecretRef references a Secret containing credentials for git + // authentication and API operations. The Secret should contain a + // GITHUB_TOKEN key for GitHub, or a BITBUCKET_TOKEN key for Bitbucket Data Center. // +optional SecretRef *SecretReference `json:"secretRef,omitempty"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3548a132..dcf13bda 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -24,6 +24,21 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BitbucketDataCenterPRs) DeepCopyInto(out *BitbucketDataCenterPRs) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BitbucketDataCenterPRs. +func (in *BitbucketDataCenterPRs) DeepCopy() *BitbucketDataCenterPRs { + if in == nil { + return nil + } + out := new(BitbucketDataCenterPRs) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Credentials) DeepCopyInto(out *Credentials) { *out = *in @@ -347,6 +362,11 @@ func (in *When) DeepCopyInto(out *When) { *out = new(GitHubIssues) (*in).DeepCopyInto(*out) } + if in.BitbucketDataCenterPRs != nil { + in, out := &in.BitbucketDataCenterPRs, &out.BitbucketDataCenterPRs + *out = new(BitbucketDataCenterPRs) + **out = **in + } if in.Cron != nil { in, out := &in.Cron, &out.Cron *out = new(Cron) diff --git a/cmd/axon-spawner/main.go b/cmd/axon-spawner/main.go index 1e2d6760..20579a49 100644 --- a/cmd/axon-spawner/main.go +++ b/cmd/axon-spawner/main.go @@ -35,12 +35,18 @@ func main() { var githubOwner string var githubRepo string var githubAPIBaseURL string + var bitbucketDCBaseURL string + var bitbucketDCProject string + var bitbucketDCRepo string flag.StringVar(&name, "taskspawner-name", "", "Name of the TaskSpawner to manage") flag.StringVar(&namespace, "taskspawner-namespace", "", "Namespace of the TaskSpawner") flag.StringVar(&githubOwner, "github-owner", "", "GitHub repository owner") flag.StringVar(&githubRepo, "github-repo", "", "GitHub repository name") flag.StringVar(&githubAPIBaseURL, "github-api-base-url", "", "GitHub API base URL for enterprise servers (e.g. https://github.example.com/api/v3)") + flag.StringVar(&bitbucketDCBaseURL, "bitbucket-dc-base-url", "", "Bitbucket Data Center base URL (e.g. https://bitbucket.example.com)") + flag.StringVar(&bitbucketDCProject, "bitbucket-dc-project", "", "Bitbucket Data Center project key") + flag.StringVar(&bitbucketDCRepo, "bitbucket-dc-repo", "", "Bitbucket Data Center repository slug") opts := zap.Options{Development: true} opts.BindFlags(flag.CommandLine) @@ -72,8 +78,17 @@ func main() { log.Info("starting spawner", "taskspawner", key) + sourceOpts := sourceOptions{ + githubOwner: githubOwner, + githubRepo: githubRepo, + githubAPIBaseURL: githubAPIBaseURL, + bitbucketDCBaseURL: bitbucketDCBaseURL, + bitbucketDCProject: bitbucketDCProject, + bitbucketDCRepo: bitbucketDCRepo, + } + for { - if err := runCycle(ctx, cl, key, githubOwner, githubRepo, githubAPIBaseURL); err != nil { + if err := runCycle(ctx, cl, key, sourceOpts); err != nil { log.Error(err, "discovery cycle failed") } @@ -93,13 +108,22 @@ func main() { } } -func runCycle(ctx context.Context, cl client.Client, key types.NamespacedName, githubOwner, githubRepo, githubAPIBaseURL string) error { +type sourceOptions struct { + githubOwner string + githubRepo string + githubAPIBaseURL string + bitbucketDCBaseURL string + bitbucketDCProject string + bitbucketDCRepo string +} + +func runCycle(ctx context.Context, cl client.Client, key types.NamespacedName, opts sourceOptions) error { var ts axonv1alpha1.TaskSpawner if err := cl.Get(ctx, key, &ts); err != nil { return fmt.Errorf("fetching TaskSpawner: %w", err) } - src, err := buildSource(&ts, githubOwner, githubRepo, githubAPIBaseURL) + src, err := buildSource(&ts, opts) if err != nil { return fmt.Errorf("building source: %w", err) } @@ -226,18 +250,29 @@ func runCycleWithSource(ctx context.Context, cl client.Client, key types.Namespa return nil } -func buildSource(ts *axonv1alpha1.TaskSpawner, owner, repo, apiBaseURL string) (source.Source, error) { +func buildSource(ts *axonv1alpha1.TaskSpawner, opts sourceOptions) (source.Source, error) { if ts.Spec.When.GitHubIssues != nil { gh := ts.Spec.When.GitHubIssues return &source.GitHubSource{ - Owner: owner, - Repo: repo, + Owner: opts.githubOwner, + Repo: opts.githubRepo, Types: gh.Types, Labels: gh.Labels, ExcludeLabels: gh.ExcludeLabels, State: gh.State, Token: os.Getenv("GITHUB_TOKEN"), - BaseURL: apiBaseURL, + BaseURL: opts.githubAPIBaseURL, + }, nil + } + + if ts.Spec.When.BitbucketDataCenterPRs != nil { + bb := ts.Spec.When.BitbucketDataCenterPRs + return &source.BitbucketDataCenterSource{ + BaseURL: opts.bitbucketDCBaseURL, + Project: opts.bitbucketDCProject, + Repo: opts.bitbucketDCRepo, + State: bb.State, + Token: os.Getenv("BITBUCKET_TOKEN"), }, nil } diff --git a/cmd/axon-spawner/main_test.go b/cmd/axon-spawner/main_test.go index 54eda99b..1f9a8456 100644 --- a/cmd/axon-spawner/main_test.go +++ b/cmd/axon-spawner/main_test.go @@ -104,7 +104,12 @@ func newTask(name, namespace, spawnerName string, phase axonv1alpha1.TaskPhase) func TestBuildSource_GitHubIssuesWithBaseURL(t *testing.T) { ts := newTaskSpawner("spawner", "default", nil) - src, err := buildSource(ts, "my-org", "my-repo", "https://github.example.com/api/v3") + opts := sourceOptions{ + githubOwner: "my-org", + githubRepo: "my-repo", + githubAPIBaseURL: "https://github.example.com/api/v3", + } + src, err := buildSource(ts, opts) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -127,7 +132,11 @@ func TestBuildSource_GitHubIssuesWithBaseURL(t *testing.T) { func TestBuildSource_GitHubIssuesDefaultBaseURL(t *testing.T) { ts := newTaskSpawner("spawner", "default", nil) - src, err := buildSource(ts, "axon-core", "axon", "") + opts := sourceOptions{ + githubOwner: "axon-core", + githubRepo: "axon", + } + src, err := buildSource(ts, opts) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -141,6 +150,57 @@ func TestBuildSource_GitHubIssuesDefaultBaseURL(t *testing.T) { } } +func TestBuildSource_BitbucketDataCenterPRs(t *testing.T) { + ts := &axonv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "spawner", + Namespace: "default", + }, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + BitbucketDataCenterPRs: &axonv1alpha1.BitbucketDataCenterPRs{ + State: "OPEN", + }, + }, + TaskTemplate: axonv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeOAuth, + SecretRef: axonv1alpha1.SecretReference{Name: "creds"}, + }, + WorkspaceRef: &axonv1alpha1.WorkspaceReference{Name: "test-ws"}, + }, + }, + } + + opts := sourceOptions{ + bitbucketDCBaseURL: "https://bitbucket.example.com", + bitbucketDCProject: "PROJ", + bitbucketDCRepo: "my-repo", + } + src, err := buildSource(ts, opts) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + bbSrc, ok := src.(*source.BitbucketDataCenterSource) + if !ok { + t.Fatalf("Expected *source.BitbucketDataCenterSource, got %T", src) + } + if bbSrc.BaseURL != "https://bitbucket.example.com" { + t.Errorf("BaseURL = %q, want %q", bbSrc.BaseURL, "https://bitbucket.example.com") + } + if bbSrc.Project != "PROJ" { + t.Errorf("Project = %q, want %q", bbSrc.Project, "PROJ") + } + if bbSrc.Repo != "my-repo" { + t.Errorf("Repo = %q, want %q", bbSrc.Repo, "my-repo") + } + if bbSrc.State != "OPEN" { + t.Errorf("State = %q, want %q", bbSrc.State, "OPEN") + } +} + func TestRunCycleWithSource_NoMaxConcurrency(t *testing.T) { ts := newTaskSpawner("spawner", "default", nil) cl, key := setupTest(t, ts) diff --git a/examples/05-taskspawner-bitbucket-dc-prs/README.md b/examples/05-taskspawner-bitbucket-dc-prs/README.md new file mode 100644 index 00000000..dcedea0f --- /dev/null +++ b/examples/05-taskspawner-bitbucket-dc-prs/README.md @@ -0,0 +1,69 @@ +# 05 — TaskSpawner for Bitbucket Data Center PRs + +A TaskSpawner that polls Bitbucket Data Center for pull requests and +automatically creates a Task for each one. This enables automated PR +review or processing for teams using Bitbucket Data Center (Server). + +## Use Case + +Automatically assign an AI agent to review every open pull request. +The agent clones the repo, reviews the PR, and provides feedback. + +## Resources + +| File | Kind | Purpose | +|------|------|---------| +| `credentials-secret.yaml` | Secret | Claude OAuth token for the agent | +| `bitbucket-token-secret.yaml` | Secret | Bitbucket HTTP access token for API polling and cloning | +| `workspace.yaml` | Workspace | Bitbucket DC repository to clone into each Task | +| `taskspawner.yaml` | TaskSpawner | Watches Bitbucket DC PRs and spawns Tasks | + +## How It Works + +``` +TaskSpawner polls Bitbucket Data Center PRs (state: OPEN) + | + +-- new PR found -> creates Task -> agent reviews PR -> provides feedback + +-- new PR found -> creates Task -> agent reviews PR -> provides feedback + +-- ... +``` + +## Steps + +1. **Edit the secrets** — replace placeholders in both secret files. + +2. **Edit `workspace.yaml`** — set your Bitbucket Data Center repository URL and branch. + +3. **Apply the resources:** + +```bash +kubectl apply -f examples/05-taskspawner-bitbucket-dc-prs/ +``` + +4. **Verify the spawner is running:** + +```bash +kubectl get taskspawners -w +``` + +5. **Create a test pull request** in your repository. The TaskSpawner picks + it up on the next poll and creates a Task. + +6. **Watch spawned Tasks:** + +```bash +kubectl get tasks -w +``` + +7. **Cleanup:** + +```bash +kubectl delete -f examples/05-taskspawner-bitbucket-dc-prs/ +``` + +## Customization + +- Change `state` in `taskspawner.yaml` to `MERGED`, `DECLINED`, or `ALL`. +- Adjust `pollInterval` to control how often Bitbucket DC is polled. +- Set `maxConcurrency` to limit how many Tasks run in parallel. +- Edit `promptTemplate` to give the agent more specific instructions. diff --git a/examples/05-taskspawner-bitbucket-dc-prs/bitbucket-token-secret.yaml b/examples/05-taskspawner-bitbucket-dc-prs/bitbucket-token-secret.yaml new file mode 100644 index 00000000..9ffa5184 --- /dev/null +++ b/examples/05-taskspawner-bitbucket-dc-prs/bitbucket-token-secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: bitbucket-token +type: Opaque +stringData: + # TODO: Replace with your Bitbucket Data Center HTTP access token + # Create one at: https://your-bitbucket.example.com/plugins/servlet/access-tokens/manage + BITBUCKET_TOKEN: "REPLACE-ME" diff --git a/examples/05-taskspawner-bitbucket-dc-prs/credentials-secret.yaml b/examples/05-taskspawner-bitbucket-dc-prs/credentials-secret.yaml new file mode 100644 index 00000000..2f27ff25 --- /dev/null +++ b/examples/05-taskspawner-bitbucket-dc-prs/credentials-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: claude-credentials +type: Opaque +stringData: + # TODO: Replace with your Claude OAuth token + CLAUDE_CODE_OAUTH_TOKEN: "REPLACE-ME" diff --git a/examples/05-taskspawner-bitbucket-dc-prs/taskspawner.yaml b/examples/05-taskspawner-bitbucket-dc-prs/taskspawner.yaml new file mode 100644 index 00000000..004cdf58 --- /dev/null +++ b/examples/05-taskspawner-bitbucket-dc-prs/taskspawner.yaml @@ -0,0 +1,30 @@ +apiVersion: axon.io/v1alpha1 +kind: TaskSpawner +metadata: + name: review-prs +spec: + when: + bitbucketDataCenterPRs: + state: OPEN + taskTemplate: + type: claude-code + workspaceRef: + name: my-workspace + credentials: + type: oauth + secretRef: + name: claude-credentials + promptTemplate: | + Review the following Bitbucket Data Center pull request and provide feedback. + + PR #{{.Number}}: {{.Title}} + + {{.Body}} + {{- if .Comments}} + + Existing comments: + {{.Comments}} + {{- end}} + ttlSecondsAfterFinished: 3600 + pollInterval: 5m + maxConcurrency: 3 diff --git a/examples/05-taskspawner-bitbucket-dc-prs/workspace.yaml b/examples/05-taskspawner-bitbucket-dc-prs/workspace.yaml new file mode 100644 index 00000000..a5685dff --- /dev/null +++ b/examples/05-taskspawner-bitbucket-dc-prs/workspace.yaml @@ -0,0 +1,14 @@ +apiVersion: axon.io/v1alpha1 +kind: Workspace +metadata: + name: my-workspace +spec: + # TODO: Replace with your Bitbucket Data Center repository URL + # Supported formats: + # - https://bitbucket.example.com/scm/PROJECT/repo.git + # - https://bitbucket.example.com/projects/PROJECT/repos/repo + # - git@bitbucket.example.com:PROJECT/repo.git + repo: https://bitbucket.example.com/scm/PROJ/my-repo.git + ref: main + secretRef: + name: bitbucket-token diff --git a/install-crd.yaml b/install-crd.yaml index a86a4ab4..e63b6b47 100644 --- a/install-crd.yaml +++ b/install-crd.yaml @@ -290,7 +290,7 @@ spec: workspaceRef: description: |- WorkspaceRef references the Workspace that defines the repository. - Required when using githubIssues source; optional for other sources. + Required when using githubIssues or bitbucketDataCenterPRs source; optional for other sources. When set, spawned Tasks inherit this workspace reference. properties: name: @@ -306,6 +306,21 @@ spec: when: description: When defines the conditions that trigger task spawning. properties: + bitbucketDataCenterPRs: + description: BitbucketDataCenterPRs discovers pull requests from + a Bitbucket Data Center repository. + properties: + state: + default: OPEN + description: State filters pull requests by state (OPEN, MERGED, + DECLINED, ALL). Defaults to OPEN. + enum: + - OPEN + - MERGED + - DECLINED + - ALL + type: string + type: object cron: description: Cron triggers task spawning on a cron schedule. properties: @@ -357,6 +372,9 @@ spec: - message: taskTemplate.workspaceRef is required when using githubIssues source rule: '!has(self.when.githubIssues) || has(self.taskTemplate.workspaceRef)' + - message: taskTemplate.workspaceRef is required when using bitbucketDataCenterPRs + source + rule: '!has(self.when.bitbucketDataCenterPRs) || has(self.taskTemplate.workspaceRef)' status: description: TaskSpawnerStatus defines the observed state of TaskSpawner. properties: @@ -443,8 +461,9 @@ spec: type: string secretRef: description: |- - SecretRef references a Secret containing a GITHUB_TOKEN key for git - authentication and GitHub CLI (gh) operations. + SecretRef references a Secret containing credentials for git + authentication and API operations. The Secret should contain a + GITHUB_TOKEN key for GitHub, or a BITBUCKET_TOKEN key for Bitbucket Data Center. properties: name: description: Name is the name of the secret. diff --git a/internal/cli/printer.go b/internal/cli/printer.go index da8d387c..c01830cb 100644 --- a/internal/cli/printer.go +++ b/internal/cli/printer.go @@ -72,6 +72,12 @@ func printTaskSpawnerTable(w io.Writer, spawners []axonv1alpha1.TaskSpawner) { } else { source = "GitHub Issues" } + } else if s.Spec.When.BitbucketDataCenterPRs != nil { + if s.Spec.TaskTemplate.WorkspaceRef != nil { + source = s.Spec.TaskTemplate.WorkspaceRef.Name + } else { + source = "Bitbucket DC PRs" + } } else if s.Spec.When.Cron != nil { source = "cron: " + s.Spec.When.Cron.Schedule } @@ -101,6 +107,12 @@ func printTaskSpawnerDetail(w io.Writer, ts *axonv1alpha1.TaskSpawner) { if len(gh.Labels) > 0 { printField(w, "Labels", fmt.Sprintf("%v", gh.Labels)) } + } else if ts.Spec.When.BitbucketDataCenterPRs != nil { + bb := ts.Spec.When.BitbucketDataCenterPRs + printField(w, "Source", "Bitbucket DC PRs") + if bb.State != "" { + printField(w, "State", bb.State) + } } else if ts.Spec.When.Cron != nil { printField(w, "Source", "Cron") printField(w, "Schedule", ts.Spec.When.Cron.Schedule) diff --git a/internal/controller/taskspawner_deployment_builder.go b/internal/controller/taskspawner_deployment_builder.go index 79b364d3..7f458e32 100644 --- a/internal/controller/taskspawner_deployment_builder.go +++ b/internal/controller/taskspawner_deployment_builder.go @@ -37,7 +37,7 @@ func NewDeploymentBuilder() *DeploymentBuilder { // Build creates a Deployment for the given TaskSpawner. // The workspace parameter provides the repository URL and optional secretRef -// for GitHub API authentication. +// for API authentication (GitHub or Bitbucket Data Center). func (b *DeploymentBuilder) Build(ts *axonv1alpha1.TaskSpawner, workspace *axonv1alpha1.WorkspaceSpec) *appsv1.Deployment { replicas := int32(1) @@ -47,7 +47,28 @@ func (b *DeploymentBuilder) Build(ts *axonv1alpha1.TaskSpawner, workspace *axonv } var envVars []corev1.EnvVar - if workspace != nil { + if workspace != nil && ts.Spec.When.BitbucketDataCenterPRs != nil { + baseURL, project, repoSlug := parseBitbucketDCRepo(workspace.Repo) + args = append(args, + "--bitbucket-dc-base-url="+baseURL, + "--bitbucket-dc-project="+project, + "--bitbucket-dc-repo="+repoSlug, + ) + + if workspace.SecretRef != nil { + envVars = append(envVars, corev1.EnvVar{ + Name: "BITBUCKET_TOKEN", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: workspace.SecretRef.Name, + }, + Key: "BITBUCKET_TOKEN", + }, + }, + }) + } + } else if workspace != nil { host, owner, repo := parseGitHubRepo(workspace.Repo) args = append(args, "--github-owner="+owner, @@ -156,3 +177,41 @@ func gitHubAPIBaseURL(host string) string { } return (&url.URL{Scheme: "https", Host: host, Path: "/api/v3"}).String() } + +// bbdcSCMRepoRe matches Bitbucket DC SCM-style clone URLs: https://host/scm/PROJECT/repo.git +var bbdcSCMRepoRe = regexp.MustCompile(`(https?://[^/]+)/scm/([^/]+)/([^/.]+)`) + +// bbdcProjectsRepoRe matches Bitbucket DC browse-style URLs: https://host/projects/PROJECT/repos/repo +var bbdcProjectsRepoRe = regexp.MustCompile(`(https?://[^/]+)/projects/([^/]+)/repos/([^/.]+)`) + +// bbdcSSHRepoRe matches Bitbucket DC SSH-style URLs: git@host:PROJECT/repo.git or ssh://git@host/PROJECT/repo.git +var bbdcSSHRepoRe = regexp.MustCompile(`(?:ssh://)?git@([^:/]+)(?::\d+)?[:/]([^/]+)/([^/.]+)`) + +// parseBitbucketDCRepo extracts the base URL, project key, and repository slug +// from a Bitbucket Data Center repository URL. +// Supports: +// - HTTPS SCM clone URL: https://bitbucket.example.com/scm/PROJECT/repo.git +// - HTTPS browse URL: https://bitbucket.example.com/projects/PROJECT/repos/repo +// - SSH URL: git@bitbucket.example.com:PROJECT/repo.git +// - SSH URL with port: ssh://git@bitbucket.example.com:7999/PROJECT/repo.git +func parseBitbucketDCRepo(repoURL string) (baseURL, project, repo string) { + repoURL = strings.TrimSuffix(repoURL, ".git") + + if m := bbdcSCMRepoRe.FindStringSubmatch(repoURL); len(m) == 4 { + return m[1], m[2], m[3] + } + if m := bbdcProjectsRepoRe.FindStringSubmatch(repoURL); len(m) == 4 { + return m[1], m[2], m[3] + } + if m := bbdcSSHRepoRe.FindStringSubmatch(repoURL); len(m) == 4 { + return "https://" + m[1], m[2], m[3] + } + + // Fallback: try splitting by '/' and taking last two segments + parts := strings.Split(strings.TrimSuffix(repoURL, "/"), "/") + if len(parts) >= 2 { + return "", parts[len(parts)-2], parts[len(parts)-1] + } + + return "", "", fmt.Sprintf("unknown-repo-%s", repoURL) +} diff --git a/internal/controller/taskspawner_deployment_builder_test.go b/internal/controller/taskspawner_deployment_builder_test.go index 55643ede..b84b7ff0 100644 --- a/internal/controller/taskspawner_deployment_builder_test.go +++ b/internal/controller/taskspawner_deployment_builder_test.go @@ -242,3 +242,133 @@ func TestBuildDeploymentWithEnterpriseURL(t *testing.T) { }) } } + +func TestParseBitbucketDCRepo(t *testing.T) { + tests := []struct { + name string + repoURL string + wantBaseURL string + wantProject string + wantRepo string + }{ + { + name: "SCM clone URL", + repoURL: "https://bitbucket.example.com/scm/PROJ/my-repo.git", + wantBaseURL: "https://bitbucket.example.com", + wantProject: "PROJ", + wantRepo: "my-repo", + }, + { + name: "SCM clone URL without .git", + repoURL: "https://bitbucket.example.com/scm/PROJ/my-repo", + wantBaseURL: "https://bitbucket.example.com", + wantProject: "PROJ", + wantRepo: "my-repo", + }, + { + name: "Browse-style URL", + repoURL: "https://bitbucket.example.com/projects/PROJ/repos/my-repo", + wantBaseURL: "https://bitbucket.example.com", + wantProject: "PROJ", + wantRepo: "my-repo", + }, + { + name: "SSH URL", + repoURL: "git@bitbucket.example.com:PROJ/my-repo.git", + wantBaseURL: "https://bitbucket.example.com", + wantProject: "PROJ", + wantRepo: "my-repo", + }, + { + name: "SSH URL with port", + repoURL: "ssh://git@bitbucket.example.com:7999/PROJ/my-repo.git", + wantBaseURL: "https://bitbucket.example.com", + wantProject: "PROJ", + wantRepo: "my-repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + baseURL, project, repo := parseBitbucketDCRepo(tt.repoURL) + if baseURL != tt.wantBaseURL { + t.Errorf("baseURL = %q, want %q", baseURL, tt.wantBaseURL) + } + if project != tt.wantProject { + t.Errorf("project = %q, want %q", project, tt.wantProject) + } + if repo != tt.wantRepo { + t.Errorf("repo = %q, want %q", repo, tt.wantRepo) + } + }) + } +} + +func TestBuildDeploymentWithBitbucketDC(t *testing.T) { + builder := NewDeploymentBuilder() + + ts := &axonv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spawner-bb", + Namespace: "default", + }, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + BitbucketDataCenterPRs: &axonv1alpha1.BitbucketDataCenterPRs{ + State: "OPEN", + }, + }, + }, + } + + workspace := &axonv1alpha1.WorkspaceSpec{ + Repo: "https://bitbucket.example.com/scm/PROJ/my-repo.git", + SecretRef: &axonv1alpha1.SecretReference{ + Name: "bb-token", + }, + } + + dep := builder.Build(ts, workspace) + args := dep.Spec.Template.Spec.Containers[0].Args + env := dep.Spec.Template.Spec.Containers[0].Env + + // Check args + expectedArgs := map[string]bool{ + "--taskspawner-name=test-spawner-bb": false, + "--taskspawner-namespace=default": false, + "--bitbucket-dc-base-url=https://bitbucket.example.com": false, + "--bitbucket-dc-project=PROJ": false, + "--bitbucket-dc-repo=my-repo": false, + } + for _, arg := range args { + if _, ok := expectedArgs[arg]; ok { + expectedArgs[arg] = true + } + } + for arg, found := range expectedArgs { + if !found { + t.Errorf("Expected arg %q not found in %v", arg, args) + } + } + + // Should NOT have github-related args + for _, arg := range args { + if len(arg) > 15 && arg[:15] == "--github-owner=" { + t.Errorf("Unexpected github-owner arg: %s", arg) + } + } + + // Check env + if len(env) != 1 { + t.Fatalf("Expected 1 env var, got %d", len(env)) + } + if env[0].Name != "BITBUCKET_TOKEN" { + t.Errorf("Expected env var BITBUCKET_TOKEN, got %s", env[0].Name) + } + if env[0].ValueFrom.SecretKeyRef.Name != "bb-token" { + t.Errorf("Expected secret name bb-token, got %s", env[0].ValueFrom.SecretKeyRef.Name) + } + if env[0].ValueFrom.SecretKeyRef.Key != "BITBUCKET_TOKEN" { + t.Errorf("Expected secret key BITBUCKET_TOKEN, got %s", env[0].ValueFrom.SecretKeyRef.Key) + } +} diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index a86a4ab4..e63b6b47 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -290,7 +290,7 @@ spec: workspaceRef: description: |- WorkspaceRef references the Workspace that defines the repository. - Required when using githubIssues source; optional for other sources. + Required when using githubIssues or bitbucketDataCenterPRs source; optional for other sources. When set, spawned Tasks inherit this workspace reference. properties: name: @@ -306,6 +306,21 @@ spec: when: description: When defines the conditions that trigger task spawning. properties: + bitbucketDataCenterPRs: + description: BitbucketDataCenterPRs discovers pull requests from + a Bitbucket Data Center repository. + properties: + state: + default: OPEN + description: State filters pull requests by state (OPEN, MERGED, + DECLINED, ALL). Defaults to OPEN. + enum: + - OPEN + - MERGED + - DECLINED + - ALL + type: string + type: object cron: description: Cron triggers task spawning on a cron schedule. properties: @@ -357,6 +372,9 @@ spec: - message: taskTemplate.workspaceRef is required when using githubIssues source rule: '!has(self.when.githubIssues) || has(self.taskTemplate.workspaceRef)' + - message: taskTemplate.workspaceRef is required when using bitbucketDataCenterPRs + source + rule: '!has(self.when.bitbucketDataCenterPRs) || has(self.taskTemplate.workspaceRef)' status: description: TaskSpawnerStatus defines the observed state of TaskSpawner. properties: @@ -443,8 +461,9 @@ spec: type: string secretRef: description: |- - SecretRef references a Secret containing a GITHUB_TOKEN key for git - authentication and GitHub CLI (gh) operations. + SecretRef references a Secret containing credentials for git + authentication and API operations. The Secret should contain a + GITHUB_TOKEN key for GitHub, or a BITBUCKET_TOKEN key for Bitbucket Data Center. properties: name: description: Name is the name of the secret. diff --git a/internal/source/bitbucket_dc.go b/internal/source/bitbucket_dc.go new file mode 100644 index 00000000..d763343f --- /dev/null +++ b/internal/source/bitbucket_dc.go @@ -0,0 +1,225 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +const ( + // maxBitbucketPages limits the number of pages fetched from the Bitbucket API. + maxBitbucketPages = 10 + + // defaultBitbucketLimit is the number of results per page. + defaultBitbucketLimit = 100 +) + +// BitbucketDataCenterSource discovers pull requests from a Bitbucket Data Center repository. +type BitbucketDataCenterSource struct { + BaseURL string // e.g. "https://bitbucket.example.com" + Project string // Bitbucket project key + Repo string // Repository slug + State string // OPEN, MERGED, DECLINED, ALL + Token string // HTTP access token for authentication + Client *http.Client +} + +type bitbucketPR struct { + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Links bitbucketPRLinks `json:"links"` +} + +type bitbucketPRLinks struct { + Self []bitbucketLink `json:"self"` +} + +type bitbucketLink struct { + Href string `json:"href"` +} + +type bitbucketPage struct { + Values json.RawMessage `json:"values"` + Size int `json:"size"` + IsLastPage bool `json:"isLastPage"` + NextPageStart int `json:"nextPageStart"` +} + +type bitbucketActivity struct { + Action string `json:"action"` + Comment *bitbucketPRNote `json:"comment,omitempty"` +} + +type bitbucketPRNote struct { + Text string `json:"text"` +} + +func (s *BitbucketDataCenterSource) httpClient() *http.Client { + if s.Client != nil { + return s.Client + } + return http.DefaultClient +} + +// Discover fetches pull requests from Bitbucket Data Center and returns them as WorkItems. +func (s *BitbucketDataCenterSource) Discover(ctx context.Context) ([]WorkItem, error) { + prs, err := s.fetchAllPRs(ctx) + if err != nil { + return nil, err + } + + var items []WorkItem + for _, pr := range prs { + comments, err := s.fetchPRComments(ctx, pr.ID) + if err != nil { + return nil, fmt.Errorf("fetching comments for PR #%d: %w", pr.ID, err) + } + + prURL := "" + if len(pr.Links.Self) > 0 { + prURL = pr.Links.Self[0].Href + } + + items = append(items, WorkItem{ + ID: strconv.Itoa(pr.ID), + Number: pr.ID, + Title: pr.Title, + Body: pr.Description, + URL: prURL, + Comments: comments, + Kind: "PR", + }) + } + + return items, nil +} + +func (s *BitbucketDataCenterSource) fetchAllPRs(ctx context.Context) ([]bitbucketPR, error) { + var allPRs []bitbucketPR + start := 0 + + for page := 0; page < maxBitbucketPages; page++ { + prs, nextStart, isLast, err := s.fetchPRsPage(ctx, start) + if err != nil { + return nil, err + } + allPRs = append(allPRs, prs...) + if isLast { + break + } + start = nextStart + } + + return allPRs, nil +} + +func (s *BitbucketDataCenterSource) fetchPRsPage(ctx context.Context, start int) ([]bitbucketPR, int, bool, error) { + u := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests", s.BaseURL, s.Project, s.Repo) + + params := url.Values{} + params.Set("limit", strconv.Itoa(defaultBitbucketLimit)) + params.Set("start", strconv.Itoa(start)) + + state := s.State + if state == "" { + state = "OPEN" + } + if state != "ALL" { + params.Set("state", state) + } + + reqURL := u + "?" + params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, 0, false, fmt.Errorf("creating request: %w", err) + } + + if s.Token != "" { + req.Header.Set("Authorization", "Bearer "+s.Token) + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, 0, false, fmt.Errorf("fetching pull requests: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, 0, false, fmt.Errorf("Bitbucket API returned status %d: %s", resp.StatusCode, string(body)) + } + + var page bitbucketPage + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return nil, 0, false, fmt.Errorf("decoding response: %w", err) + } + + var prs []bitbucketPR + if err := json.Unmarshal(page.Values, &prs); err != nil { + return nil, 0, false, fmt.Errorf("decoding pull requests: %w", err) + } + + return prs, page.NextPageStart, page.IsLastPage, nil +} + +func (s *BitbucketDataCenterSource) fetchPRComments(ctx context.Context, prID int) (string, error) { + u := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/activities", + s.BaseURL, s.Project, s.Repo, prID) + + params := url.Values{} + params.Set("limit", strconv.Itoa(defaultBitbucketLimit)) + reqURL := u + "?" + params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + if s.Token != "" { + req.Header.Set("Authorization", "Bearer "+s.Token) + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("fetching activities: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("Bitbucket API returned status %d: %s", resp.StatusCode, string(body)) + } + + var page bitbucketPage + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return "", fmt.Errorf("decoding activities response: %w", err) + } + + var activities []bitbucketActivity + if err := json.Unmarshal(page.Values, &activities); err != nil { + return "", fmt.Errorf("decoding activities: %w", err) + } + + var parts []string + totalBytes := 0 + for _, a := range activities { + if a.Action != "COMMENTED" || a.Comment == nil { + continue + } + totalBytes += len(a.Comment.Text) + if totalBytes > maxCommentBytes { + break + } + parts = append(parts, a.Comment.Text) + } + + return strings.Join(parts, "\n---\n"), nil +} diff --git a/internal/source/bitbucket_dc_test.go b/internal/source/bitbucket_dc_test.go new file mode 100644 index 00000000..61e1301e --- /dev/null +++ b/internal/source/bitbucket_dc_test.go @@ -0,0 +1,320 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBitbucketDCDiscover(t *testing.T) { + prs := []bitbucketPR{ + {ID: 1, Title: "Add feature", Description: "Feature body", State: "OPEN", Links: bitbucketPRLinks{Self: []bitbucketLink{{Href: "https://bb.example.com/projects/PROJ/repos/repo/pull-requests/1"}}}}, + {ID: 2, Title: "Fix bug", Description: "Bug body", State: "OPEN", Links: bitbucketPRLinks{Self: []bitbucketLink{{Href: "https://bb.example.com/projects/PROJ/repos/repo/pull-requests/2"}}}}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests": + page := bitbucketPage{IsLastPage: true, Size: len(prs)} + raw, _ := json.Marshal(prs) + page.Values = raw + json.NewEncoder(w).Encode(page) + case r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests/1/activities" || + r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests/2/activities": + page := bitbucketPage{IsLastPage: true, Size: 0} + page.Values, _ = json.Marshal([]bitbucketActivity{}) + json.NewEncoder(w).Encode(page) + } + })) + defer server.Close() + + s := &BitbucketDataCenterSource{ + BaseURL: server.URL, + Project: "PROJ", + Repo: "repo", + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(items) != 2 { + t.Fatalf("Expected 2 items, got %d", len(items)) + } + if items[0].ID != "1" || items[0].Title != "Add feature" || items[0].Body != "Feature body" { + t.Errorf("Unexpected item[0]: %+v", items[0]) + } + if items[0].Kind != "PR" { + t.Errorf("Expected Kind 'PR', got %q", items[0].Kind) + } + if items[0].URL != "https://bb.example.com/projects/PROJ/repos/repo/pull-requests/1" { + t.Errorf("Unexpected URL: %s", items[0].URL) + } + if items[1].Number != 2 { + t.Errorf("Expected Number 2, got %d", items[1].Number) + } +} + +func TestBitbucketDCDiscoverStateFilter(t *testing.T) { + var receivedQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests" { + receivedQuery = r.URL.RawQuery + page := bitbucketPage{IsLastPage: true} + page.Values, _ = json.Marshal([]bitbucketPR{}) + json.NewEncoder(w).Encode(page) + } + })) + defer server.Close() + + s := &BitbucketDataCenterSource{ + BaseURL: server.URL, + Project: "PROJ", + Repo: "repo", + State: "MERGED", + } + + _, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !containsParam(receivedQuery, "state=MERGED") { + t.Errorf("Expected state=MERGED in query: %s", receivedQuery) + } +} + +func TestBitbucketDCDiscoverStateALL(t *testing.T) { + var receivedQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests" { + receivedQuery = r.URL.RawQuery + page := bitbucketPage{IsLastPage: true} + page.Values, _ = json.Marshal([]bitbucketPR{}) + json.NewEncoder(w).Encode(page) + } + })) + defer server.Close() + + s := &BitbucketDataCenterSource{ + BaseURL: server.URL, + Project: "PROJ", + Repo: "repo", + State: "ALL", + } + + _, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // ALL should not send the state parameter + if containsParam(receivedQuery, "state=") { + t.Errorf("Expected no state param for ALL, got query: %s", receivedQuery) + } +} + +func TestBitbucketDCDiscoverAuthHeader(t *testing.T) { + var authHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests" { + authHeader = r.Header.Get("Authorization") + page := bitbucketPage{IsLastPage: true} + page.Values, _ = json.Marshal([]bitbucketPR{}) + json.NewEncoder(w).Encode(page) + } + })) + defer server.Close() + + // With token + s := &BitbucketDataCenterSource{ + BaseURL: server.URL, + Project: "PROJ", + Repo: "repo", + Token: "test-token", + } + + _, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if authHeader != "Bearer test-token" { + t.Errorf("Expected 'Bearer test-token', got %q", authHeader) + } + + // Without token + authHeader = "" + s.Token = "" + _, err = s.Discover(context.Background()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if authHeader != "" { + t.Errorf("Expected no auth header, got %q", authHeader) + } +} + +func TestBitbucketDCDiscoverPagination(t *testing.T) { + page1PRs := []bitbucketPR{{ID: 1, Title: "PR 1", Description: "Body 1"}} + page2PRs := []bitbucketPR{{ID: 2, Title: "PR 2", Description: "Body 2"}} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests": + start := r.URL.Query().Get("start") + if start == "1" { + raw, _ := json.Marshal(page2PRs) + page := bitbucketPage{IsLastPage: true, Size: 1, Values: raw} + json.NewEncoder(w).Encode(page) + return + } + raw, _ := json.Marshal(page1PRs) + page := bitbucketPage{IsLastPage: false, Size: 1, NextPageStart: 1, Values: raw} + json.NewEncoder(w).Encode(page) + case r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests/1/activities" || + r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests/2/activities": + page := bitbucketPage{IsLastPage: true} + page.Values, _ = json.Marshal([]bitbucketActivity{}) + json.NewEncoder(w).Encode(page) + } + })) + defer server.Close() + + s := &BitbucketDataCenterSource{ + BaseURL: server.URL, + Project: "PROJ", + Repo: "repo", + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(items) != 2 { + t.Fatalf("Expected 2 items, got %d", len(items)) + } + if items[0].Number != 1 || items[1].Number != 2 { + t.Errorf("Unexpected items: %+v", items) + } +} + +func TestBitbucketDCDiscoverAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"errors":[{"message":"Authentication required"}]}`)) + })) + defer server.Close() + + s := &BitbucketDataCenterSource{ + BaseURL: server.URL, + Project: "PROJ", + Repo: "repo", + } + + _, err := s.Discover(context.Background()) + if err == nil { + t.Fatal("Expected error, got nil") + } +} + +func TestBitbucketDCDiscoverEmptyResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := bitbucketPage{IsLastPage: true} + page.Values, _ = json.Marshal([]bitbucketPR{}) + json.NewEncoder(w).Encode(page) + })) + defer server.Close() + + s := &BitbucketDataCenterSource{ + BaseURL: server.URL, + Project: "PROJ", + Repo: "repo", + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(items) != 0 { + t.Fatalf("Expected 0 items, got %d", len(items)) + } +} + +func TestBitbucketDCDiscoverComments(t *testing.T) { + prs := []bitbucketPR{ + {ID: 42, Title: "Fix bug", Description: "Details"}, + } + + activities := []bitbucketActivity{ + {Action: "COMMENTED", Comment: &bitbucketPRNote{Text: "First comment"}}, + {Action: "APPROVED"}, + {Action: "COMMENTED", Comment: &bitbucketPRNote{Text: "Second comment"}}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests": + raw, _ := json.Marshal(prs) + page := bitbucketPage{IsLastPage: true, Size: 1, Values: raw} + json.NewEncoder(w).Encode(page) + case fmt.Sprintf("/rest/api/1.0/projects/PROJ/repos/repo/pull-requests/%d/activities", 42): + raw, _ := json.Marshal(activities) + page := bitbucketPage{IsLastPage: true, Size: len(activities), Values: raw} + json.NewEncoder(w).Encode(page) + } + })) + defer server.Close() + + s := &BitbucketDataCenterSource{ + BaseURL: server.URL, + Project: "PROJ", + Repo: "repo", + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(items)) + } + expected := "First comment\n---\nSecond comment" + if items[0].Comments != expected { + t.Errorf("Expected comments %q, got %q", expected, items[0].Comments) + } +} + +func TestBitbucketDCDiscoverDefaultState(t *testing.T) { + var receivedQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests" { + receivedQuery = r.URL.RawQuery + page := bitbucketPage{IsLastPage: true} + page.Values, _ = json.Marshal([]bitbucketPR{}) + json.NewEncoder(w).Encode(page) + } + })) + defer server.Close() + + s := &BitbucketDataCenterSource{ + BaseURL: server.URL, + Project: "PROJ", + Repo: "repo", + // State not set — should default to OPEN + } + + _, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !containsParam(receivedQuery, "state=OPEN") { + t.Errorf("Expected state=OPEN in query: %s", receivedQuery) + } +} diff --git a/test/integration/taskspawner_test.go b/test/integration/taskspawner_test.go index 7201de2a..d71b4754 100644 --- a/test/integration/taskspawner_test.go +++ b/test/integration/taskspawner_test.go @@ -626,6 +626,196 @@ var _ = Describe("TaskSpawner Controller", func() { }) }) + Context("When creating a TaskSpawner with Bitbucket Data Center PRs source", func() { + It("Should create a Deployment with Bitbucket DC args", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-taskspawner-bbdc", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a Workspace with Bitbucket DC repo URL") + ws := &axonv1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-workspace-bbdc", + Namespace: ns.Name, + }, + Spec: axonv1alpha1.WorkspaceSpec{ + Repo: "https://bitbucket.example.com/scm/PROJ/my-repo.git", + Ref: "main", + }, + } + Expect(k8sClient.Create(ctx, ws)).Should(Succeed()) + + By("Creating a TaskSpawner with Bitbucket DC PRs source") + ts := &axonv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spawner-bbdc", + Namespace: ns.Name, + }, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + BitbucketDataCenterPRs: &axonv1alpha1.BitbucketDataCenterPRs{ + State: "OPEN", + }, + }, + TaskTemplate: axonv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeOAuth, + SecretRef: axonv1alpha1.SecretReference{ + Name: "claude-credentials", + }, + }, + WorkspaceRef: &axonv1alpha1.WorkspaceReference{ + Name: "test-workspace-bbdc", + }, + }, + PollInterval: "5m", + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + tsLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdTS := &axonv1alpha1.TaskSpawner{} + + By("Verifying the TaskSpawner has a finalizer") + Eventually(func() bool { + err := k8sClient.Get(ctx, tsLookupKey, createdTS) + if err != nil { + return false + } + for _, f := range createdTS.Finalizers { + if f == "axon.io/taskspawner-finalizer" { + return true + } + } + return false + }, timeout, interval).Should(BeTrue()) + + By("Verifying a Deployment is created") + deployLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdDeploy := &appsv1.Deployment{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, deployLookupKey, createdDeploy) + return err == nil + }, timeout, interval).Should(BeTrue()) + + By("Verifying the Deployment spec has Bitbucket DC args") + Expect(createdDeploy.Spec.Template.Spec.Containers).To(HaveLen(1)) + container := createdDeploy.Spec.Template.Spec.Containers[0] + Expect(container.Name).To(Equal("spawner")) + Expect(container.Image).To(Equal(controller.DefaultSpawnerImage)) + Expect(container.Args).To(ConsistOf( + "--taskspawner-name="+ts.Name, + "--taskspawner-namespace="+ns.Name, + "--bitbucket-dc-base-url=https://bitbucket.example.com", + "--bitbucket-dc-project=PROJ", + "--bitbucket-dc-repo=my-repo", + )) + + By("Verifying the Deployment has no env vars (no secretRef)") + Expect(container.Env).To(BeEmpty()) + + By("Verifying TaskSpawner status has deploymentName") + Eventually(func() string { + err := k8sClient.Get(ctx, tsLookupKey, createdTS) + if err != nil { + return "" + } + return createdTS.Status.DeploymentName + }, timeout, interval).Should(Equal(ts.Name)) + + By("Verifying TaskSpawner phase is Pending") + Expect(createdTS.Status.Phase).To(Equal(axonv1alpha1.TaskSpawnerPhasePending)) + }) + }) + + Context("When creating a TaskSpawner with Bitbucket DC and secretRef", func() { + It("Should create a Deployment with BITBUCKET_TOKEN env var", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-taskspawner-bbdc-token", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a Secret with Bitbucket token") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bitbucket-token", + Namespace: ns.Name, + }, + StringData: map[string]string{ + "BITBUCKET_TOKEN": "test-bitbucket-token", + }, + } + Expect(k8sClient.Create(ctx, secret)).Should(Succeed()) + + By("Creating a Workspace with secretRef") + ws := &axonv1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-workspace-bbdc-token", + Namespace: ns.Name, + }, + Spec: axonv1alpha1.WorkspaceSpec{ + Repo: "https://bitbucket.example.com/scm/PROJ/my-repo.git", + Ref: "main", + SecretRef: &axonv1alpha1.SecretReference{ + Name: "bitbucket-token", + }, + }, + } + Expect(k8sClient.Create(ctx, ws)).Should(Succeed()) + + By("Creating a TaskSpawner") + ts := &axonv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spawner-bbdc-token", + Namespace: ns.Name, + }, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + BitbucketDataCenterPRs: &axonv1alpha1.BitbucketDataCenterPRs{}, + }, + TaskTemplate: axonv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeOAuth, + SecretRef: axonv1alpha1.SecretReference{ + Name: "claude-credentials", + }, + }, + WorkspaceRef: &axonv1alpha1.WorkspaceReference{ + Name: "test-workspace-bbdc-token", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + By("Verifying a Deployment is created") + deployLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdDeploy := &appsv1.Deployment{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, deployLookupKey, createdDeploy) + return err == nil + }, timeout, interval).Should(BeTrue()) + + By("Verifying the Deployment has BITBUCKET_TOKEN env var") + container := createdDeploy.Spec.Template.Spec.Containers[0] + Expect(container.Env).To(HaveLen(1)) + Expect(container.Env[0].Name).To(Equal("BITBUCKET_TOKEN")) + Expect(container.Env[0].ValueFrom.SecretKeyRef.Name).To(Equal("bitbucket-token")) + Expect(container.Env[0].ValueFrom.SecretKeyRef.Key).To(Equal("BITBUCKET_TOKEN")) + }) + }) + Context("When creating a TaskSpawner with Cron source", func() { It("Should create a Deployment and update status", func() { By("Creating a namespace")