Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion api/v1alpha1/taskspawner_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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).
Expand All @@ -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"`
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions api/v1alpha1/workspace_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
20 changes: 20 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 42 additions & 7 deletions cmd/axon-spawner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand Down
64 changes: 62 additions & 2 deletions cmd/axon-spawner/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions examples/05-taskspawner-bitbucket-dc-prs/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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"
30 changes: 30 additions & 0 deletions examples/05-taskspawner-bitbucket-dc-prs/taskspawner.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading