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