diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..a4f0047a --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,100 @@ +FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm + +USER root + +SHELL ["/bin/bash", "-euo", "pipefail", "-c"] + +# kind (for local K8s clusters) +RUN curl -Lo /usr/local/bin/kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 \ + && chmod +x /usr/local/bin/kind + +# Claude Code +RUN curl -fsSL https://claude.ai/install.sh | bash + +# asdf (for teleport-ent) +# renovate: datasource=github-releases depName=asdf-vm/asdf +ENV V_ASDF="0.18.1" +RUN <>/etc/zsh/zshrc + echo "export PATH=\"\$ASDF_DATA_DIR/shims:\$PATH\"" >>/etc/zsh/zshrc + echo "export ASDF_DATA_DIR='/root/.asdf'" >>/etc/bash.bashrc + echo "export PATH=\"\$ASDF_DATA_DIR/shims:\$PATH\"" >>/etc/bash.bashrc + asdf plugin add teleport-ent + asdf install teleport-ent 18.2.2 + asdf set --home teleport-ent 18.2.2 +EOF + +# AWS IAM Roles Anywhere +RUN < /root/.aws/config +[default] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere/svid.pem --private-key /opt/roles-anywhere/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-west-1:580663733917:profile/1c5babda-a4a9-4c75-9a16-88e143ab0233 --trust-anchor-arn +arn:aws:rolesanywhere:us-west-1:580663733917:trust-anchor/b15762fd-2a5f-4bcd-a09f-17f63f4f2f98 --role-arn arn:aws:iam::580663733917:role/TeleportDeveloperAccess +output = json +[profile teleport-admin] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-admin/svid.pem --private-key /opt/roles-anywhere-admin/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-west-1:580663733917:profile/32163bf3-dd34-4b50-8ff2-bea86d8daf3b --trust-anchor-arn +arn:aws:rolesanywhere:us-west-1:580663733917:trust-anchor/b15762fd-2a5f-4bcd-a09f-17f63f4f2f98 --role-arn arn:aws:iam::580663733917:role/TeleportAdministratorAccess +output = json +[profile sandbox] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-sandbox/svid.pem --private-key /opt/roles-anywhere-sandbox/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-east-1:774922483191:profile/bf98f431-d465-48d8-84b1-6d3090bb17aa --trust-anchor-arn +arn:aws:rolesanywhere:us-east-1:774922483191:trust-anchor/9f687ddc-5ae6-4459-bd1f-ed9c7d296f25 --role-arn arn:aws:iam::774922483191:role/TeleportRolesAnywhere +region = us-west-1 +output = json +[profile bd] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-bd/svid.pem --private-key /opt/roles-anywhere-bd/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-west-1:580663733917:profile/e61115ef-b5d7-415f-b249-6fd1f3814aa1 --trust-anchor-arn +arn:aws:rolesanywhere:us-west-1:580663733917:trust-anchor/b15762fd-2a5f-4bcd-a09f-17f63f4f2f98 --role-arn arn:aws:iam::580663733917:role/TeleportBusinessDevelopmentAccess +output = json +[profile cs] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-cs/svid.pem --private-key /opt/roles-anywhere-cs/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-west-1:580663733917:profile/8bbcfc3e-f5e2-4d17-a7c4-fb10f648b10e --trust-anchor-arn +arn:aws:rolesanywhere:us-west-1:580663733917:trust-anchor/b15762fd-2a5f-4bcd-a09f-17f63f4f2f98 --role-arn arn:aws:iam::580663733917:role/TeleportCustomerSolutionsAccess +output = json +[profile gtm] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-gtm/svid.pem --private-key /opt/roles-anywhere-gtm/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-east-1:649126925216:profile/de329f63-770f-462c-9152-ea822877c9f3 --trust-anchor-arn +arn:aws:rolesanywhere:us-east-1:649126925216:trust-anchor/a784c1f4-8247-4b9d-b565-d430fec69f15 --role-arn arn:aws:iam::649126925216:role/TeleportAdminAccess +region = us-west-1 +output = json +EOF +EOB + +# claude-bedrock helper and gimme-creds aliases +RUN <<'EOF' +cat >> /etc/zshrc << 'SHELL_RC' + +# Claude / Bedrock helper function +claude-bedrock() { + CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" +} + +# Teleport credential helpers +alias gimme-creds="tsh svid issue --output /opt/roles-anywhere --svid-ttl 12h /svc/codespaces" +alias gimme-admin-creds="tsh svid issue --output /opt/roles-anywhere-admin --svid-ttl 12h /role/administrator" +alias gimme-sandbox-creds="tsh svid issue --output /opt/roles-anywhere-sandbox --svid-ttl 12h /cloud/aws-sandbox" +SHELL_RC +cat >> /etc/bash.bashrc << 'SHELL_RC' + +# Claude / Bedrock helper function +claude-bedrock() { + CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" +} + +# Teleport credential helpers +alias gimme-creds="tsh svid issue --output /opt/roles-anywhere --svid-ttl 12h /svc/codespaces" +alias gimme-admin-creds="tsh svid issue --output /opt/roles-anywhere-admin --svid-ttl 12h /role/administrator" +alias gimme-sandbox-creds="tsh svid issue --output /opt/roles-anywhere-sandbox --svid-ttl 12h /cloud/aws-sandbox" +SHELL_RC +EOF + +# Needed for teleport & credentials +RUN <>/etc/zshrc + echo 'export TELEPORT_PROXY=anomalo.teleport.sh:443' >>/etc/zshrc + echo 'export TELEPORT_AUTH=google' >>/etc/bash.bashrc + echo 'export TELEPORT_PROXY=anomalo.teleport.sh:443' >>/etc/bash.bashrc +EOF diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..d6be13ef --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,44 @@ +{ + "name": "Kelos Dev", + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "root", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "kubectl": "latest", + "helm": "latest", + "minikube": "none" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {}, + "ghcr.io/tailscale/codespace/tailscale:1": {} + }, + "customizations": { + "vscode": { + "settings": { + "go.toolsManagement.autoUpdate": true, + "go.useLanguageServer": true, + "go.lintTool": "golangci-lint", + "terminal.integrated.defaultProfile.linux": "zsh" + }, + "extensions": [ + "anthropic.claude-code", + "golang.go", + "eamodio.gitlens", + "redhat.vscode-yaml", + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] + } + }, + "forwardPorts": [], + "postCreateCommand": "/bin/bash .devcontainer/post-create.sh", + "remoteEnv": { + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + }, + "hostRequirements": { + "cpus": 4, + "memory": "8gb" + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 00000000..59a60efd --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -euo pipefail + +echo "==> Installing Go tool dependencies..." +make -C /workspaces/kelos controller-gen envtest yamlfmt shfmt 2>/dev/null || true + +echo "==> Downloading Go modules..." +cd /workspaces/kelos && go mod download + +echo "==> Building kelos CLI..." +make -C /workspaces/kelos build WHAT=cmd/kelos 2>/dev/null || true + +cat <<'MSG' + +==> Done! To get started: +1. tailscale up --accept-routes +2. tsh login --proxy=anomalo.teleport.sh:443 --auth=google +3. gimme-creds +4. claude-bedrock +MSG diff --git a/.githooks/check-secrets.sh b/.githooks/check-secrets.sh new file mode 100755 index 00000000..361e9f15 --- /dev/null +++ b/.githooks/check-secrets.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Pre-commit hook: check staged files for secrets or sensitive information + +set -euo pipefail + +RED='\033[0;31m' +NC='\033[0m' + +# Patterns that suggest secrets or sensitive info +PATTERNS=( + 'AKIA[0-9A-Z]{16}' # AWS Access Key ID + '["\x27]sk-[a-zA-Z0-9]{20,}' # OpenAI / Stripe secret keys + 'ghp_[a-zA-Z0-9]{36}' # GitHub personal access token + 'github_pat_[a-zA-Z0-9_]{22,}' # GitHub fine-grained PAT + 'glpat-[a-zA-Z0-9\-]{20,}' # GitLab PAT + 'xox[bpors]-[a-zA-Z0-9\-]+' # Slack tokens + '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----' # Private keys + 'password\s*[:=]\s*["\x27][^"\x27]{4,}' # password assignments + 'secret\s*[:=]\s*["\x27][^"\x27]{4,}' # secret assignments + 'api[_-]?key\s*[:=]\s*["\x27][^"\x27]{4,}' # API key assignments + 'token\s*[:=]\s*["\x27][^"\x27]{4,}' # token assignments + 'AIza[0-9A-Za-z\-_]{35}' # Google API key + '[0-9]+-[0-9A-Za-z_]{32}\.apps\.googleusercontent\.com' # Google OAuth client ID + 'sk-ant-api[0-9]{2}-[a-zA-Z0-9\-_]{80,}' # Anthropic API key + 'sk-ant-[a-zA-Z0-9\-_]{40,}' # Anthropic API key (older format) + 'sk-proj-[a-zA-Z0-9\-_]{40,}' # OpenAI project API key + 'sk-[a-zA-Z0-9]{48}' # OpenAI API key (legacy) + 'ya29\.[a-zA-Z0-9_\-]{50,}' # Google/Vertex OAuth access token +) + +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true) + +if [ -z "$STAGED_FILES" ]; then + exit 0 +fi + +FOUND=0 + +for file in $STAGED_FILES; do + # Skip binary files and common non-secret files + if [[ "$file" =~ \.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|pdf|zip|tar|gz)$ ]]; then + continue + fi + # Skip this hook script itself and test fixtures + if [[ "$file" == *"check-secrets"* ]] || [[ "$file" == *"testdata"* ]] || [[ "$file" == *"test/fixtures"* ]]; then + continue + fi + + CONTENT=$(git show ":$file" 2>/dev/null || true) + if [ -z "$CONTENT" ]; then + continue + fi + + for pattern in "${PATTERNS[@]}"; do + MATCHES=$(echo "$CONTENT" | grep -nEi "$pattern" 2>/dev/null || true) + if [ -n "$MATCHES" ]; then + echo -e "${RED}Possible secret found in ${file}:${NC}" + echo "$MATCHES" | head -5 + echo "" + FOUND=1 + fi + done +done + +# Check for common sensitive filenames +SENSITIVE_FILES=( + '.env' + '.env.local' + '.env.production' + 'credentials.json' + 'service-account.json' + 'id_rsa' + 'id_ed25519' + '.npmrc' + '.pypirc' + 'kubeconfig' +) + +for file in $STAGED_FILES; do + basename=$(basename "$file") + for sensitive in "${SENSITIVE_FILES[@]}"; do + if [ "$basename" = "$sensitive" ]; then + echo -e "${RED}Sensitive file staged for commit: ${file}${NC}" + FOUND=1 + fi + done +done + +if [ "$FOUND" -eq 1 ]; then + echo -e "${RED}Commit blocked: potential secrets detected.${NC}" + echo "If these are false positives, commit with --no-verify to bypass." + exit 1 +fi + +exit 0 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c97293b6..c2d9bb4f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, prod] pull_request: - branches: [main] + branches: [main, prod] types: [opened, synchronize, reopened, labeled] merge_group: workflow_dispatch: @@ -90,17 +90,17 @@ jobs: cluster_name: kind - name: Build images - run: make image VERSION=e2e + run: make image VERSION=e2e REGISTRY=public.ecr.aws/anomalo/kelos - name: Load images into kind run: | - kind load docker-image ghcr.io/kelos-dev/kelos-controller:e2e - kind load docker-image ghcr.io/kelos-dev/kelos-spawner:e2e - kind load docker-image ghcr.io/kelos-dev/claude-code:e2e - kind load docker-image ghcr.io/kelos-dev/codex:e2e - kind load docker-image ghcr.io/kelos-dev/gemini:e2e - kind load docker-image ghcr.io/kelos-dev/opencode:e2e - kind load docker-image ghcr.io/kelos-dev/cursor:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/kelos-controller:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/kelos-spawner:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/claude-code:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/codex:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/gemini:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/opencode:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/cursor:e2e - name: Build CLI run: make build WHAT=cmd/kelos diff --git a/.github/workflows/deploy-dev.yaml b/.github/workflows/deploy-dev.yaml index 47de0830..51469dbf 100644 --- a/.github/workflows/deploy-dev.yaml +++ b/.github/workflows/deploy-dev.yaml @@ -1,65 +1,65 @@ -name: Deploy to Dev +# name: Deploy to Dev -on: - workflow_run: - workflows: [Release] - types: [completed] - branches: [main] - workflow_dispatch: +# on: +# workflow_run: +# workflows: [Release] +# types: [completed] +# branches: [main] +# workflow_dispatch: -permissions: - contents: read - id-token: write +# permissions: +# contents: read +# id-token: write -concurrency: - group: deploy-dev-gke - cancel-in-progress: false +# concurrency: +# group: deploy-dev-gke +# cancel-in-progress: false -jobs: - deploy: - if: (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - env: - KELOS_NAMESPACE: ${{ vars.KELOS_NAMESPACE || 'default' }} - GCP_PROJECT_ID: gjkim-400213 - GKE_CLUSTER_NAME: gjkim - GKE_CLUSTER_LOCATION: asia-northeast3 - GCP_SERVICE_ACCOUNT_EMAIL: kelos-gh-action@gjkim-400213.iam.gserviceaccount.com - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/317215297044/locations/global/workloadIdentityPools/github/providers/kelos - steps: - - uses: actions/checkout@v4 +# jobs: +# deploy: +# if: (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || github.event.workflow_run.conclusion == 'success' +# runs-on: ubuntu-latest +# env: +# KELOS_NAMESPACE: ${{ vars.KELOS_NAMESPACE || 'default' }} +# GCP_PROJECT_ID: gjkim-400213 +# GKE_CLUSTER_NAME: gjkim +# GKE_CLUSTER_LOCATION: asia-northeast3 +# GCP_SERVICE_ACCOUNT_EMAIL: kelos-gh-action@gjkim-400213.iam.gserviceaccount.com +# GCP_WORKLOAD_IDENTITY_PROVIDER: projects/317215297044/locations/global/workloadIdentityPools/github/providers/kelos +# steps: +# - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod +# - uses: actions/setup-go@v5 +# with: +# go-version-file: go.mod - - name: Build CLI - run: make build WHAT=cmd/kelos +# - name: Build CLI +# run: make build WHAT=cmd/kelos - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ env.GCP_SERVICE_ACCOUNT_EMAIL }} +# - name: Authenticate to Google Cloud +# uses: google-github-actions/auth@v2 +# with: +# workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} +# service_account: ${{ env.GCP_SERVICE_ACCOUNT_EMAIL }} - - name: Configure GKE credentials - uses: google-github-actions/get-gke-credentials@v2 - with: - cluster_name: ${{ env.GKE_CLUSTER_NAME }} - location: ${{ env.GKE_CLUSTER_LOCATION }} - project_id: ${{ env.GCP_PROJECT_ID }} +# - name: Configure GKE credentials +# uses: google-github-actions/get-gke-credentials@v2 +# with: +# cluster_name: ${{ env.GKE_CLUSTER_NAME }} +# location: ${{ env.GKE_CLUSTER_LOCATION }} +# project_id: ${{ env.GCP_PROJECT_ID }} - - name: Install kelos - run: | - bin/kelos install --version main --image-pull-policy Always \ - --spawner-resource-requests cpu=100m,memory=128Mi \ - --token-refresher-resource-requests cpu=50m,memory=64Mi \ - --controller-resource-requests cpu=10m,memory=64Mi \ - --controller-resource-limits cpu=500m,memory=128Mi - kubectl rollout restart deployment/kelos-controller-manager -n kelos-system - kubectl rollout status deployment/kelos-controller-manager -n kelos-system --timeout=120s - kubectl rollout restart deployment -l app.kubernetes.io/component=spawner -n "${KELOS_NAMESPACE}" - kubectl rollout status deployment -l app.kubernetes.io/component=spawner -n "${KELOS_NAMESPACE}" --timeout=120s +# - name: Install kelos +# run: | +# bin/kelos install --version main --image-pull-policy Always \ +# --spawner-resource-requests cpu=100m,memory=128Mi \ +# --token-refresher-resource-requests cpu=50m,memory=64Mi \ +# --controller-resource-requests cpu=10m,memory=64Mi \ +# --controller-resource-limits cpu=500m,memory=128Mi +# kubectl rollout restart deployment/kelos-controller-manager -n kelos-system +# kubectl rollout status deployment/kelos-controller-manager -n kelos-system --timeout=120s +# kubectl rollout restart deployment -l app.kubernetes.io/component=spawner -n "${KELOS_NAMESPACE}" +# kubectl rollout status deployment -l app.kubernetes.io/component=spawner -n "${KELOS_NAMESPACE}" --timeout=120s - - name: Apply self-development resources - run: kubectl apply -f self-development/ -n "${KELOS_NAMESPACE}" +# - name: Apply self-development resources +# run: kubectl apply -f self-development/ -n "${KELOS_NAMESPACE}" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ffb90d06..e5ab29d0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,19 +2,24 @@ name: Release on: push: - branches: [main] + branches: [main, prod] tags: ["v*"] concurrency: group: release cancel-in-progress: false +env: + REGISTRY: public.ecr.aws + IMAGE_NAME: anomalo/kelos + jobs: release: runs-on: ubuntu-latest permissions: contents: write packages: write + id-token: write steps: - uses: actions/checkout@v4 with: @@ -24,12 +29,20 @@ jobs: with: go-version-file: go.mod - - name: Login to GHCR - uses: docker/login-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + role-to-assume: arn:aws:iam::580663733917:role/github-actions + aws-region: us-east-1 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + with: + registry-type: public - name: Determine version id: version @@ -37,24 +50,20 @@ jobs: if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" else - echo "version=main" >> "$GITHUB_OUTPUT" + echo "version=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" fi - - name: Build images + - name: Build and push multi-arch images env: VERSION: ${{ steps.version.outputs.version }} - run: make image VERSION="$VERSION" - - - name: Push images - env: - VERSION: ${{ steps.version.outputs.version }} - run: make push VERSION="$VERSION" + BUILDX_CACHE: --cache-from type=gha --cache-to type=gha,mode=max + run: make push-multiarch VERSION="$VERSION" REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - name: Push latest tags for releases if: startsWith(github.ref, 'refs/tags/v') - run: | - make image VERSION=latest - make push VERSION=latest + env: + BUILDX_CACHE: --cache-from type=gha + run: make push-multiarch VERSION=latest REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - name: Build CLI binaries if: startsWith(github.ref, 'refs/tags/v') diff --git a/.github/workflows/run-fake-strategist.yaml b/.github/workflows/run-fake-strategist.yaml deleted file mode 100644 index 9391bc65..00000000 --- a/.github/workflows/run-fake-strategist.yaml +++ /dev/null @@ -1,80 +0,0 @@ -name: Run Fake Strategist - -on: - workflow_dispatch: - inputs: - namespace: - description: Kubernetes namespace for Kelos resources - required: false - default: "" - -permissions: - contents: read - id-token: write - -concurrency: - group: fake-strategist-gke - cancel-in-progress: false - -jobs: - run-fake-strategist: - runs-on: ubuntu-latest - env: - KELOS_NAMESPACE: ${{ inputs.namespace || vars.KELOS_NAMESPACE || 'default' }} - GCP_PROJECT_ID: gjkim-400213 - GKE_CLUSTER_NAME: gjkim - GKE_CLUSTER_LOCATION: asia-northeast3 - GCP_SERVICE_ACCOUNT_EMAIL: kelos-gh-action@gjkim-400213.iam.gserviceaccount.com - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/317215297044/locations/global/workloadIdentityPools/github/providers/kelos - steps: - - uses: actions/checkout@v4 - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ env.GCP_SERVICE_ACCOUNT_EMAIL }} - - - name: Configure GKE credentials - uses: google-github-actions/get-gke-credentials@v2 - with: - cluster_name: ${{ env.GKE_CLUSTER_NAME }} - location: ${{ env.GKE_CLUSTER_LOCATION }} - project_id: ${{ env.GCP_PROJECT_ID }} - - - name: Create strategist task if none active - id: create_task - run: | - set -euo pipefail - - existing_task="$( - kubectl get tasks.kelos.dev -n "${KELOS_NAMESPACE}" -l kelos.dev/type=fake-strategist-manual -o json \ - | jq -r '.items[] | select(.status.phase == "Pending" or .status.phase == "Running") | .metadata.name' \ - | head -n1 - )" - - if [[ -n "${existing_task}" ]]; then - echo "action=skipped" >> "$GITHUB_OUTPUT" - echo "task_name=${existing_task}" >> "$GITHUB_OUTPUT" - exit 0 - fi - - task_name="$( - kubectl create -n "${KELOS_NAMESPACE}" -f self-development/tasks/fake-strategist-task.yaml -o jsonpath='{.metadata.name}' - )" - - echo "action=created" >> "$GITHUB_OUTPUT" - echo "task_name=${task_name}" >> "$GITHUB_OUTPUT" - - - name: Print task result - env: - TASK_ACTION: ${{ steps.create_task.outputs.action }} - TASK_NAME: ${{ steps.create_task.outputs.task_name }} - run: | - set -euo pipefail - if [[ "${TASK_ACTION}" == "skipped" ]]; then - echo "Fake strategist task already active: ${TASK_NAME} in namespace ${KELOS_NAMESPACE}" - else - echo "Created fake strategist task: ${TASK_NAME} in namespace ${KELOS_NAMESPACE}" - echo "Check status with: kubectl get task ${TASK_NAME} -n ${KELOS_NAMESPACE} -o yaml" - fi diff --git a/Makefile b/Makefile index 19a60ac1..64e543c9 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Image configuration -REGISTRY ?= ghcr.io/kelos-dev +REGISTRY ?= public.ecr.aws/anomalo/kelos VERSION ?= latest -IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-token-refresher claude-code codex gemini opencode cursor +IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-token-refresher cmd/kelos-webhook-receiver claude-code codex gemini opencode cursor # Version injection for the kelos CLI – only set ldflags when an explicit # version is given so that dev builds fall through to runtime/debug info. @@ -81,10 +81,6 @@ run: ## Run a controller from your host. .PHONY: image image: ## Build docker images (use WHAT to build specific image). - @for dir in $(filter cmd/%,$(or $(WHAT),$(IMAGE_DIRS))); do \ - GOOS=linux GOARCH=amd64 $(MAKE) build WHAT=$$dir; \ - done - @GOOS=linux GOARCH=amd64 $(MAKE) build WHAT=cmd/kelos-capture @for dir in $(or $(WHAT),$(IMAGE_DIRS)); do \ docker build -t $(REGISTRY)/$$(basename $$dir):$(VERSION) -f $$dir/Dockerfile .; \ done @@ -95,6 +91,19 @@ push: ## Push docker images (use WHAT to push specific image). docker push $(REGISTRY)/$$(basename $$dir):$(VERSION); \ done +DOCKER_PLATFORMS ?= linux/amd64,linux/arm64 + +BUILDX_CACHE ?= + +.PHONY: push-multiarch +push-multiarch: ## Build and push multi-arch docker images. + @for dir in $(or $(WHAT),$(IMAGE_DIRS)); do \ + docker buildx build --platform $(DOCKER_PLATFORMS) \ + $(BUILDX_CACHE) \ + -t $(REGISTRY)/$$(basename $$dir):$(VERSION) \ + -f $$dir/Dockerfile --push .; \ + done + RELEASE_PLATFORMS ?= linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 .PHONY: release-binaries diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index f4dd0e7d..88f3439c 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -29,6 +29,14 @@ type When struct { // +optional GitHubPullRequests *GitHubPullRequests `json:"githubPullRequests,omitempty"` + // GitHubWebhook discovers issues and pull requests from GitHub webhooks. + // +optional + GitHubWebhook *GitHubWebhook `json:"githubWebhook,omitempty"` + + // LinearWebhook discovers issues from Linear webhooks. + // +optional + LinearWebhook *LinearWebhook `json:"linearWebhook,omitempty"` + // Cron triggers task spawning on a cron schedule. // +optional Cron *Cron `json:"cron,omitempty"` @@ -260,6 +268,53 @@ type GitHubPullRequests struct { PollInterval string `json:"pollInterval,omitempty"` } +// GitHubWebhook discovers issues and pull requests from GitHub webhook events. +// Instead of polling the GitHub API, work items are discovered from webhook +// payloads received by the kelos-webhook-receiver and stored as WebhookEvent +// custom resources. +type GitHubWebhook struct { + // Namespace is the Kubernetes namespace where WebhookEvent resources are created. + // The spawner will watch for GitHub webhook events in this namespace. + // +kubebuilder:validation:Required + Namespace string `json:"namespace"` + + // Labels filters issues/PRs by labels (applied client-side to webhook payloads). + // +optional + Labels []string `json:"labels,omitempty"` + + // ExcludeLabels filters out issues/PRs that have any of these labels (client-side). + // +optional + ExcludeLabels []string `json:"excludeLabels,omitempty"` + + // Reporting configures status reporting back to GitHub. + // +optional + Reporting *GitHubReporting `json:"reporting,omitempty"` +} + +// LinearWebhook discovers issues from Linear webhooks. +// Linear webhooks must be configured to POST to the kelos-webhook-receiver +// endpoint at /webhook/linear. The webhook receiver creates WebhookEvent +// CRDs that the spawner processes. +type LinearWebhook struct { + // Namespace is the Kubernetes namespace where WebhookEvent resources are created. + // The spawner will watch for Linear webhook events in this namespace. + // +kubebuilder:validation:Required + Namespace string `json:"namespace"` + + // States filters issues by workflow state names (e.g., ["Todo", "In Progress"]). + // When empty, all non-terminal states are processed (excludes "Done", "Canceled"). + // +optional + States []string `json:"states,omitempty"` + + // Labels filters issues by labels (applied client-side to webhook payloads). + // +optional + Labels []string `json:"labels,omitempty"` + + // ExcludeLabels filters out issues that have any of these labels (client-side). + // +optional + ExcludeLabels []string `json:"excludeLabels,omitempty"` +} + // Jira discovers issues from a Jira project. // Authentication is provided via a Secret referenced in the TaskSpawner's // namespace. The secret must contain a "JIRA_TOKEN" key. For Jira Cloud, diff --git a/api/v1alpha1/webhookevent_types.go b/api/v1alpha1/webhookevent_types.go new file mode 100644 index 00000000..46154a7c --- /dev/null +++ b/api/v1alpha1/webhookevent_types.go @@ -0,0 +1,66 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WebhookEventSpec defines the desired state of WebhookEvent. +type WebhookEventSpec struct { + // Source is the webhook source type (e.g., "github", "slack", "linear"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Source string `json:"source"` + + // Payload is the raw webhook payload as JSON bytes. + // +kubebuilder:validation:Required + Payload []byte `json:"payload"` + + // ReceivedAt is the timestamp when the webhook was received. + // +kubebuilder:validation:Required + ReceivedAt metav1.Time `json:"receivedAt"` +} + +// WebhookEventStatus defines the observed state of WebhookEvent. +type WebhookEventStatus struct { + // Processed indicates whether this event has been processed by a source. + // +optional + Processed bool `json:"processed,omitempty"` + + // ProcessedAt is the timestamp when the event was processed. + // +optional + ProcessedAt *metav1.Time `json:"processedAt,omitempty"` + + // Message provides additional information about processing. + // +optional + Message string `json:"message,omitempty"` +} + +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Source",type=string,JSONPath=`.spec.source` +// +kubebuilder:printcolumn:name="Processed",type=boolean,JSONPath=`.status.processed` +// +kubebuilder:printcolumn:name="ReceivedAt",type=date,JSONPath=`.spec.receivedAt` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// WebhookEvent is the Schema for the webhookevents API. +type WebhookEvent struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec WebhookEventSpec `json:"spec,omitempty"` + Status WebhookEventStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// WebhookEventList contains a list of WebhookEvent. +type WebhookEventList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []WebhookEvent `json:"items"` +} + +func init() { + SchemeBuilder.Register(&WebhookEvent{}, &WebhookEventList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 83ace025..ed75683b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -328,6 +328,36 @@ func (in *GitHubReporting) DeepCopy() *GitHubReporting { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitHubWebhook) DeepCopyInto(out *GitHubWebhook) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExcludeLabels != nil { + in, out := &in.ExcludeLabels, &out.ExcludeLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Reporting != nil { + in, out := &in.Reporting, &out.Reporting + *out = new(GitHubReporting) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitHubWebhook. +func (in *GitHubWebhook) DeepCopy() *GitHubWebhook { + if in == nil { + return nil + } + out := new(GitHubWebhook) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitRemote) DeepCopyInto(out *GitRemote) { *out = *in @@ -359,6 +389,36 @@ func (in *Jira) DeepCopy() *Jira { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinearWebhook) DeepCopyInto(out *LinearWebhook) { + *out = *in + if in.States != nil { + in, out := &in.States, &out.States + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExcludeLabels != nil { + in, out := &in.ExcludeLabels, &out.ExcludeLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinearWebhook. +func (in *LinearWebhook) DeepCopy() *LinearWebhook { + if in == nil { + return nil + } + out := new(LinearWebhook) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = *in @@ -821,6 +881,105 @@ func (in *TaskTemplate) DeepCopy() *TaskTemplate { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookEvent) DeepCopyInto(out *WebhookEvent) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookEvent. +func (in *WebhookEvent) DeepCopy() *WebhookEvent { + if in == nil { + return nil + } + out := new(WebhookEvent) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WebhookEvent) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookEventList) DeepCopyInto(out *WebhookEventList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]WebhookEvent, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookEventList. +func (in *WebhookEventList) DeepCopy() *WebhookEventList { + if in == nil { + return nil + } + out := new(WebhookEventList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WebhookEventList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookEventSpec) DeepCopyInto(out *WebhookEventSpec) { + *out = *in + if in.Payload != nil { + in, out := &in.Payload, &out.Payload + *out = make([]byte, len(*in)) + copy(*out, *in) + } + in.ReceivedAt.DeepCopyInto(&out.ReceivedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookEventSpec. +func (in *WebhookEventSpec) DeepCopy() *WebhookEventSpec { + if in == nil { + return nil + } + out := new(WebhookEventSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookEventStatus) DeepCopyInto(out *WebhookEventStatus) { + *out = *in + if in.ProcessedAt != nil { + in, out := &in.ProcessedAt, &out.ProcessedAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookEventStatus. +func (in *WebhookEventStatus) DeepCopy() *WebhookEventStatus { + if in == nil { + return nil + } + out := new(WebhookEventStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *When) DeepCopyInto(out *When) { *out = *in @@ -834,6 +993,16 @@ func (in *When) DeepCopyInto(out *When) { *out = new(GitHubPullRequests) (*in).DeepCopyInto(*out) } + if in.GitHubWebhook != nil { + in, out := &in.GitHubWebhook, &out.GitHubWebhook + *out = new(GitHubWebhook) + (*in).DeepCopyInto(*out) + } + if in.LinearWebhook != nil { + in, out := &in.LinearWebhook, &out.LinearWebhook + *out = new(LinearWebhook) + (*in).DeepCopyInto(*out) + } if in.Cron != nil { in, out := &in.Cron, &out.Cron *out = new(Cron) diff --git a/claude-code/Dockerfile b/claude-code/Dockerfile index 6e3352ef..b27e2e03 100644 --- a/claude-code/Dockerfile +++ b/claude-code/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} COPY claude-code/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash claude RUN mkdir -p /home/claude/.claude && chown -R claude:claude /home/claude diff --git a/cmd/kelos-controller/Dockerfile b/cmd/kelos-controller/Dockerfile index bbe8cb11..12b1af87 100644 --- a/cmd/kelos-controller/Dockerfile +++ b/cmd/kelos-controller/Dockerfile @@ -1,5 +1,13 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -o bin/kelos-controller ./cmd/kelos-controller + FROM gcr.io/distroless/static:nonroot WORKDIR / -COPY bin/kelos-controller . +COPY --from=builder /workspace/bin/kelos-controller . USER 65532:65532 ENTRYPOINT ["/kelos-controller"] diff --git a/cmd/kelos-spawner/Dockerfile b/cmd/kelos-spawner/Dockerfile index a1851bc0..36a0a39b 100644 --- a/cmd/kelos-spawner/Dockerfile +++ b/cmd/kelos-spawner/Dockerfile @@ -1,5 +1,13 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -o bin/kelos-spawner ./cmd/kelos-spawner + FROM gcr.io/distroless/static:nonroot WORKDIR / -COPY bin/kelos-spawner . +COPY --from=builder /workspace/bin/kelos-spawner . USER 65532:65532 ENTRYPOINT ["/kelos-spawner"] diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index 70f45487..b91408f9 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -174,7 +174,7 @@ func runCycle(ctx context.Context, cl client.Client, key types.NamespacedName, g return fmt.Errorf("fetching TaskSpawner: %w", err) } - src, err := buildSource(&ts, githubOwner, githubRepo, githubAPIBaseURL, githubTokenFile, jiraBaseURL, jiraProject, jiraJQL, httpClient) + src, err := buildSource(&ts, githubOwner, githubRepo, githubAPIBaseURL, githubTokenFile, jiraBaseURL, jiraProject, jiraJQL, httpClient, cl) if err != nil { return fmt.Errorf("building source: %w", err) } @@ -505,7 +505,7 @@ func resolveGitHubCommentPolicy(policy *kelosv1alpha1.GitHubCommentPolicy, legac }, nil } -func buildSource(ts *kelosv1alpha1.TaskSpawner, owner, repo, apiBaseURL, tokenFile, jiraBaseURL, jiraProject, jiraJQL string, httpClient *http.Client) (source.Source, error) { +func buildSource(ts *kelosv1alpha1.TaskSpawner, owner, repo, apiBaseURL, tokenFile, jiraBaseURL, jiraProject, jiraJQL string, httpClient *http.Client, k8sClient client.Client) (source.Source, error) { if ts.Spec.When.GitHubIssues != nil { gh := ts.Spec.When.GitHubIssues token, err := readGitHubToken(tokenFile) @@ -569,6 +569,27 @@ func buildSource(ts *kelosv1alpha1.TaskSpawner, owner, repo, apiBaseURL, tokenFi }, nil } + if ts.Spec.When.GitHubWebhook != nil { + webhook := ts.Spec.When.GitHubWebhook + return &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: webhook.Namespace, + Labels: webhook.Labels, + ExcludeLabels: webhook.ExcludeLabels, + }, nil + } + + if ts.Spec.When.LinearWebhook != nil { + webhook := ts.Spec.When.LinearWebhook + return &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: webhook.Namespace, + States: webhook.States, + Labels: webhook.Labels, + ExcludeLabels: webhook.ExcludeLabels, + }, nil + } + if ts.Spec.When.Jira != nil { user := os.Getenv("JIRA_USER") token := os.Getenv("JIRA_TOKEN") diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index 5b4cb21f..be9efe3b 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -112,7 +112,7 @@ func newTask(name, namespace, spawnerName string, phase kelosv1alpha1.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", "", "", "", "", nil) + src, err := buildSource(ts, "my-org", "my-repo", "https://github.example.com/api/v3", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -135,7 +135,7 @@ func TestBuildSource_GitHubIssuesWithBaseURL(t *testing.T) { func TestBuildSource_GitHubIssuesDefaultBaseURL(t *testing.T) { ts := newTaskSpawner("spawner", "default", nil) - src, err := buildSource(ts, "kelos-dev", "kelos", "", "", "", "", "", nil) + src, err := buildSource(ts, "kelos-dev", "kelos", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -161,7 +161,7 @@ func TestBuildSource_GitHubPullRequests(t *testing.T) { }, } - src, err := buildSource(ts, "kelos-dev", "kelos", "https://github.example.com/api/v3", "", "", "", "", nil) + src, err := buildSource(ts, "kelos-dev", "kelos", "https://github.example.com/api/v3", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -221,7 +221,7 @@ func TestBuildSource_Jira(t *testing.T) { t.Setenv("JIRA_USER", "user@example.com") t.Setenv("JIRA_TOKEN", "jira-api-token") - src, err := buildSource(ts, "", "", "", "", "https://mycompany.atlassian.net", "PROJ", "status = Open", nil) + src, err := buildSource(ts, "", "", "", "", "https://mycompany.atlassian.net", "PROJ", "status = Open", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1002,7 +1002,7 @@ func TestBuildSource_PriorityLabelsPassedToSource(t *testing.T) { "priority/imporant-soon", } - src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil) + src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1029,7 +1029,7 @@ func TestRunCycleWithSource_CommentFieldsPassedToSource(t *testing.T) { ExcludeComments: []string{"/kelos needs-input"}, } - src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil) + src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1058,7 +1058,7 @@ func TestBuildSource_CommentPolicyPassedToIssueSource(t *testing.T) { }, } - src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil) + src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1098,7 +1098,7 @@ func TestBuildSource_CommentPolicyPassedToPullRequestSource(t *testing.T) { }, } - src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil) + src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1163,7 +1163,7 @@ func TestBuildSource_CommentPolicyRejectsMixedConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := buildSource(tt.ts, "owner", "repo", "", "", "", "", "", nil) + _, err := buildSource(tt.ts, "owner", "repo", "", "", "", "", "", nil, nil) if err == nil { t.Fatal("Expected error for mixed legacy and commentPolicy config") } diff --git a/cmd/kelos-token-refresher/Dockerfile b/cmd/kelos-token-refresher/Dockerfile index f012daec..3639bc7e 100644 --- a/cmd/kelos-token-refresher/Dockerfile +++ b/cmd/kelos-token-refresher/Dockerfile @@ -1,25 +1,17 @@ -# Build stage FROM golang:1.25 AS builder - WORKDIR /workspace - -# Copy go mod files COPY go.mod go.sum ./ RUN go mod download +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -o bin/kelos-token-refresher ./cmd/kelos-token-refresher # Copy source COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-token-refresher ./cmd/kelos-token-refresher -# Build -RUN make build WHAT=cmd/kelos-token-refresher - -# Runtime stage FROM gcr.io/distroless/static:nonroot - WORKDIR / - COPY --from=builder /workspace/bin/kelos-token-refresher . - USER 65532:65532 - ENTRYPOINT ["/kelos-token-refresher"] diff --git a/cmd/kelos-webhook-receiver/Dockerfile b/cmd/kelos-webhook-receiver/Dockerfile new file mode 100644 index 00000000..357acf09 --- /dev/null +++ b/cmd/kelos-webhook-receiver/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-webhook-receiver ./cmd/kelos-webhook-receiver + +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=builder /workspace/bin/kelos-webhook-receiver . +USER 65532:65532 +ENTRYPOINT ["/kelos-webhook-receiver"] diff --git a/cmd/kelos-webhook-receiver/main.go b/cmd/kelos-webhook-receiver/main.go new file mode 100644 index 00000000..aa652865 --- /dev/null +++ b/cmd/kelos-webhook-receiver/main.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/logging" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelosv1alpha1.AddToScheme(scheme)) +} + +func main() { + var namespace string + var port int + + flag.StringVar(&namespace, "namespace", "default", "Namespace to create WebhookEvent resources in") + flag.IntVar(&port, "port", 8080, "HTTP server port") + + opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine) + flag.Parse() + + if err := applyVerbosity(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + logger := zap.New(zap.UseFlagOptions(opts)) + ctrl.SetLogger(logger) + log := ctrl.Log.WithName("webhook-receiver") + + cfg, err := ctrl.GetConfig() + if err != nil { + log.Error(err, "unable to get kubeconfig") + os.Exit(1) + } + + cl, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + log.Error(err, "unable to create client") + os.Exit(1) + } + + handler := &webhookHandler{ + client: cl, + namespace: namespace, + log: log, + } + + http.HandleFunc("/webhook/", handler.handle) + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + addr := fmt.Sprintf(":%d", port) + log.Info("Starting webhook receiver", "address", addr, "namespace", namespace) + + if err := http.ListenAndServe(addr, nil); err != nil { + log.Error(err, "HTTP server failed") + os.Exit(1) + } +} + +type webhookHandler struct { + client client.Client + namespace string + log logr.Logger +} + +func (h *webhookHandler) handle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract source from path: /webhook/{source} + path := strings.TrimPrefix(r.URL.Path, "/webhook/") + source := strings.Trim(path, "/") + + if source == "" { + http.Error(w, "Source not specified in path", http.StatusBadRequest) + return + } + + // Read payload + body, err := io.ReadAll(r.Body) + if err != nil { + h.log.Error(err, "Failed to read request body") + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Validate signature for GitHub webhooks + if source == "github" { + if err := validateGitHubSignature(r.Header, body); err != nil { + h.log.Error(err, "GitHub signature validation failed") + http.Error(w, "Signature validation failed", http.StatusUnauthorized) + return + } + } + + // Validate signature for Linear webhooks + if source == "linear" { + if err := validateLinearSignature(r.Header, body); err != nil { + h.log.Error(err, "Linear signature validation failed") + http.Error(w, "Signature validation failed", http.StatusUnauthorized) + return + } + } + + // Create WebhookEvent CRD + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-webhook-", source), + Namespace: h.namespace, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: source, + Payload: body, + ReceivedAt: metav1.Now(), + }, + } + + ctx := context.Background() + if err := h.client.Create(ctx, event); err != nil { + h.log.Error(err, "Failed to create WebhookEvent", "source", source) + http.Error(w, "Failed to store event", http.StatusInternalServerError) + return + } + + h.log.Info("Webhook received and stored", "source", source, "event", event.Name) + + w.WriteHeader(http.StatusAccepted) + w.Write([]byte("Webhook received")) +} + +// validateGitHubSignature validates the X-Hub-Signature-256 header against the payload. +// The secret is read from the GITHUB_WEBHOOK_SECRET environment variable. +func validateGitHubSignature(headers http.Header, payload []byte) error { + signature := headers.Get("X-Hub-Signature-256") + if signature == "" { + return fmt.Errorf("missing X-Hub-Signature-256 header") + } + + secret := os.Getenv("GITHUB_WEBHOOK_SECRET") + if secret == "" { + // If no secret is configured, skip validation (development mode) + return nil + } + + // Compute expected signature + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expectedMAC := mac.Sum(nil) + expectedSignature := "sha256=" + hex.EncodeToString(expectedMAC) + + if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + return fmt.Errorf("signature mismatch") + } + + return nil +} + +// validateLinearSignature validates the X-Linear-Signature header against the payload. +// The secret is read from the LINEAR_WEBHOOK_SECRET environment variable. +func validateLinearSignature(headers http.Header, payload []byte) error { + signature := headers.Get("X-Linear-Signature") + if signature == "" { + return fmt.Errorf("missing X-Linear-Signature header") + } + + secret := os.Getenv("LINEAR_WEBHOOK_SECRET") + if secret == "" { + // If no secret is configured, skip validation (development mode) + return nil + } + + // Compute expected signature + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expectedMAC := mac.Sum(nil) + expectedSignature := hex.EncodeToString(expectedMAC) + + if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + return fmt.Errorf("signature mismatch") + } + + return nil +} diff --git a/cmd/kelos-webhook-receiver/main_test.go b/cmd/kelos-webhook-receiver/main_test.go new file mode 100644 index 00000000..46e1aebd --- /dev/null +++ b/cmd/kelos-webhook-receiver/main_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestValidateGitHubSignature_ValidSignature(t *testing.T) { + // Set up environment + t.Setenv("GITHUB_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"opened"}`) + + // Compute expected signature + // echo -n '{"action":"opened"}' | openssl dgst -sha256 -hmac 'test-secret' + // Result: sha256=6e939b5b3d3e8eba83ff81dde0030a8f2190d965e8bec7a17842863e979c4d7d + expectedSig := "sha256=6e939b5b3d3e8eba83ff81dde0030a8f2190d965e8bec7a17842863e979c4d7d" + + headers := http.Header{} + headers.Set("X-Hub-Signature-256", expectedSig) + + err := validateGitHubSignature(headers, payload) + if err != nil { + t.Errorf("Expected valid signature, got error: %v", err) + } +} + +func TestValidateGitHubSignature_InvalidSignature(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"opened"}`) + + headers := http.Header{} + headers.Set("X-Hub-Signature-256", "sha256=wrongsignature") + + err := validateGitHubSignature(headers, payload) + if err == nil { + t.Error("Expected error for invalid signature, got nil") + } +} + +func TestValidateGitHubSignature_MissingHeader(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"opened"}`) + headers := http.Header{} + + err := validateGitHubSignature(headers, payload) + if err == nil { + t.Error("Expected error for missing signature header, got nil") + } +} + +func TestValidateGitHubSignature_NoSecretConfigured(t *testing.T) { + // Don't set GITHUB_WEBHOOK_SECRET - should skip validation + t.Setenv("GITHUB_WEBHOOK_SECRET", "") + + payload := []byte(`{"action":"opened"}`) + headers := http.Header{} + headers.Set("X-Hub-Signature-256", "sha256=anysignature") + + err := validateGitHubSignature(headers, payload) + if err != nil { + t.Errorf("Expected no error when secret not configured, got: %v", err) + } +} + +func TestWebhookHandler_MethodNotAllowed(t *testing.T) { + handler := &webhookHandler{} + + req := httptest.NewRequest(http.MethodGet, "/webhook/github", nil) + w := httptest.NewRecorder() + + handler.handle(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) + } +} + +func TestWebhookHandler_MissingSource(t *testing.T) { + handler := &webhookHandler{} + + req := httptest.NewRequest(http.MethodPost, "/webhook/", nil) + w := httptest.NewRecorder() + + handler.handle(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestValidateLinearSignature_ValidSignature(t *testing.T) { + t.Setenv("LINEAR_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"create","type":"Issue"}`) + + // Compute expected signature + // echo -n '{"action":"create","type":"Issue"}' | openssl dgst -sha256 -hmac 'test-secret' + expectedSig := "3b4c0e7668708bcb65b6103de3d28cae0bead64460615aaa232f645b96568741" + + headers := http.Header{} + headers.Set("X-Linear-Signature", expectedSig) + + err := validateLinearSignature(headers, payload) + if err != nil { + t.Errorf("Expected valid signature, got error: %v", err) + } +} + +func TestValidateLinearSignature_InvalidSignature(t *testing.T) { + t.Setenv("LINEAR_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"create","type":"Issue"}`) + + headers := http.Header{} + headers.Set("X-Linear-Signature", "wrongsignature") + + err := validateLinearSignature(headers, payload) + if err == nil { + t.Error("Expected error for invalid signature, got nil") + } +} + +func TestValidateLinearSignature_MissingHeader(t *testing.T) { + t.Setenv("LINEAR_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"create","type":"Issue"}`) + headers := http.Header{} + + err := validateLinearSignature(headers, payload) + if err == nil { + t.Error("Expected error for missing signature header, got nil") + } +} + +func TestValidateLinearSignature_NoSecretConfigured(t *testing.T) { + // Don't set LINEAR_WEBHOOK_SECRET - should skip validation + t.Setenv("LINEAR_WEBHOOK_SECRET", "") + + payload := []byte(`{"action":"create","type":"Issue"}`) + headers := http.Header{} + headers.Set("X-Linear-Signature", "anysignature") + + err := validateLinearSignature(headers, payload) + if err != nil { + t.Errorf("Expected no error when secret not configured, got: %v", err) + } +} diff --git a/codex/Dockerfile b/codex/Dockerfile index 397dd250..9a7ed9ca 100644 --- a/codex/Dockerfile +++ b/codex/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g @openai/codex@${CODEX_VERSION} COPY codex/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.codex && chown -R agent:agent /home/agent diff --git a/cursor/Dockerfile b/cursor/Dockerfile index 4bd0f8ae..24b4f01e 100644 --- a/cursor/Dockerfile +++ b/cursor/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -30,7 +37,7 @@ ENV PATH="/usr/local/go/bin:${PATH}" COPY cursor/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.cursor && chown -R agent:agent /home/agent diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 00000000..aea3e936 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,267 @@ +# Webhook Support + +Kelos supports webhooks for work item discovery. Instead of polling external APIs, Kelos can receive push notifications when issues or pull requests are created or updated. + +Supported webhook sources: +- **GitHub**: Issues and Pull Requests +- **Linear**: Issues + +## Architecture + +The webhook system consists of three components: + +### 1. WebhookEvent CRD + +Webhook payloads are stored as `WebhookEvent` custom resources in Kubernetes, providing: + +- **Persistence**: Events survive pod restarts (stored in etcd) +- **Auditability**: All events are visible via `kubectl get webhookevents` +- **Processing tracking**: Events are marked as processed after discovery + +### 2. Webhook Receiver (kelos-webhook-receiver) + +An HTTP server that: +- Listens on `/webhook/github` +- Validates GitHub webhook signatures (HMAC-SHA256) +- Creates `WebhookEvent` CRD instances +- Returns 202 Accepted + +Deploy as a Deployment with a LoadBalancer Service to expose it publicly. + +### 3. GitHubWebhookSource + +The `GitHubWebhookSource` implementation: +- Lists unprocessed `WebhookEvent` resources +- Parses GitHub webhook payloads into `WorkItem` format +- Applies filters (labels, state, etc.) +- Marks events as processed + +## GitHub Webhook Setup + +### 1. Deploy the webhook receiver + +```bash +kubectl apply -f examples/taskspawner-github-webhook.yaml +``` + +This creates: +- `kelos-webhook-receiver` Deployment +- LoadBalancer Service +- RBAC for creating WebhookEvent resources + +### 2. Get the external URL + +```bash +kubectl get service kelos-webhook-receiver -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' +``` + +### 3. Configure GitHub webhook + +In your GitHub repository settings: +1. Go to Settings → Webhooks → Add webhook +2. **Payload URL**: `http:///webhook/github` +3. **Content type**: `application/json` +4. **Secret**: Set a secret and store it in the `github-webhook-secret` Secret +5. **Events**: Select "Issues" and "Pull requests" +6. Click "Add webhook" + +### 4. Create a TaskSpawner with githubWebhook + +```yaml +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: my-webhook-spawner +spec: + when: + githubWebhook: + namespace: default + labels: + - "kelos-task" + taskTemplate: + type: claude-code + credentials: + type: api-key + secretRef: + name: anthropic-api-key + workspaceRef: + name: my-workspace + promptTemplate: | + {{ .Title }} + {{ .Body }} +``` + +## Webhook Signature Validation + +For GitHub webhooks, the receiver validates the `X-Hub-Signature-256` header using HMAC-SHA256. + +Set the `GITHUB_WEBHOOK_SECRET` environment variable on the webhook receiver to enable validation: + +```yaml +env: +- name: GITHUB_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: github-webhook-secret + key: secret +``` + +If the secret is not set, signature validation is skipped (development mode only). + +--- + +# Linear Webhook Support + +Kelos supports Linear webhooks for work item discovery. Instead of polling the Linear API, Kelos can receive push notifications when issues are created or updated. + +## Architecture + +The Linear webhook system uses the same architecture as GitHub webhooks: + +### 1. WebhookEvent CRD + +Webhook payloads are stored as `WebhookEvent` custom resources with `source: linear`. + +### 2. Webhook Receiver (kelos-webhook-receiver) + +The HTTP server: +- Listens on `/webhook/linear` +- Validates Linear webhook signatures (HMAC-SHA256) +- Creates `WebhookEvent` CRD instances with `source: linear` +- Returns 202 Accepted + +### 3. LinearWebhookSource + +The `LinearWebhookSource` implementation: +- Lists unprocessed `WebhookEvent` resources with `source: linear` +- Parses Linear webhook payloads (Issue create/update events) +- Applies filters (states, labels, excludeLabels) +- Excludes terminal states (completed, canceled) by default +- Marks events as processed + +## Linear Webhook Setup + +### 1. Deploy the webhook receiver + +The same webhook receiver handles both GitHub and Linear webhooks: + +```bash +kubectl apply -f examples/taskspawner-linear-webhook.yaml +``` + +This creates: +- `kelos-webhook-receiver` Deployment +- LoadBalancer Service +- RBAC for creating WebhookEvent resources + +### 2. Get the external URL + +```bash +kubectl get service kelos-webhook-receiver -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' +``` + +### 3. Configure Linear webhook + +In your Linear workspace settings: +1. Go to Settings → API → Webhooks +2. Click "Create webhook" +3. **URL**: `http:///webhook/linear` +4. **Secret**: Set a secret and store it in the `linear-webhook-secret` Secret +5. **Events**: Select "Issue" events (create, update) +6. Click "Create" + +### 4. Create a TaskSpawner with linearWebhook + +```yaml +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: my-linear-spawner +spec: + when: + linearWebhook: + namespace: default + states: + - "Todo" + - "In Progress" + labels: + - "kelos-task" + taskTemplate: + type: claude-code + credentials: + type: api-key + secretRef: + name: anthropic-api-key + workspaceRef: + name: my-workspace + promptTemplate: | + {{ .Title }} + {{ .Body }} +``` + +## Linear Webhook Configuration + +### State Filtering + +Control which Linear issue states are processed: + +```yaml +when: + linearWebhook: + namespace: default + states: + - "Todo" + - "In Progress" +``` + +**Default behavior** (when `states` is not specified): +- Accepts all non-terminal states +- Excludes `completed` and `canceled` states + +### Label Filtering + +Require specific labels: + +```yaml +when: + linearWebhook: + namespace: default + labels: + - "bug" + - "high-priority" +``` + +Exclude specific labels: + +```yaml +when: + linearWebhook: + namespace: default + excludeLabels: + - "wont-fix" +``` + +## Webhook Signature Validation + +The receiver validates the `X-Linear-Signature` header using HMAC-SHA256. + +Set the `LINEAR_WEBHOOK_SECRET` environment variable to enable validation: + +```yaml +env: +- name: LINEAR_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: linear-webhook-secret + key: secret +``` + +If the secret is not set, signature validation is skipped (development mode only). + +## Event Types + +Linear webhook events include: +- **Issue create**: New issues matching your filters will be discovered +- **Issue update**: State changes and label updates will re-trigger discovery + +Only Issue events are processed. Comment and other event types are ignored. diff --git a/examples/taskspawner-github-webhook.yaml b/examples/taskspawner-github-webhook.yaml new file mode 100644 index 00000000..a2db8b28 --- /dev/null +++ b/examples/taskspawner-github-webhook.yaml @@ -0,0 +1,126 @@ +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: example-webhook + namespace: default +spec: + when: + githubWebhook: + # Namespace where kelos-webhook-receiver creates WebhookEvent resources + namespace: default + # Optional: filter by labels (applied client-side to webhook payloads) + labels: + - "kelos-task" + # Optional: exclude items with these labels + excludeLabels: + - "skip-kelos" + # Optional: post status comments back to GitHub + reporting: + enabled: true + + taskTemplate: + type: claude-code + credentials: + type: api-key + secretRef: + name: anthropic-api-key + workspaceRef: + name: my-workspace + promptTemplate: | + {{ .Title }} + + {{ .Body }} + + Please investigate and resolve this {{ .Kind }}. + +--- +# Webhook receiver deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-receiver + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: kelos-webhook-receiver + template: + metadata: + labels: + app: kelos-webhook-receiver + spec: + serviceAccountName: kelos-webhook-receiver + containers: + - name: receiver + image: ghcr.io/kelos-dev/kelos-webhook-receiver:latest + args: + - --namespace=default + - --port=8080 + ports: + - containerPort: 8080 + name: http + env: + # Optional: GitHub webhook secret for signature validation + - name: GITHUB_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: github-webhook-secret + key: secret + livenessProbe: + httpGet: + path: /health + port: 8080 + readinessProbe: + httpGet: + path: /health + port: 8080 + +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-receiver + namespace: default +spec: + selector: + app: kelos-webhook-receiver + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + type: LoadBalancer + +--- +# ServiceAccount and RBAC for webhook receiver +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kelos-webhook-receiver + namespace: default + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: kelos-webhook-receiver + namespace: default +rules: + - apiGroups: ["kelos.dev"] + resources: ["webhookevents"] + verbs: ["create", "get", "list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: kelos-webhook-receiver + namespace: default +subjects: + - kind: ServiceAccount + name: kelos-webhook-receiver + namespace: default +roleRef: + kind: Role + name: kelos-webhook-receiver + apiGroup: rbac.authorization.k8s.io diff --git a/examples/taskspawner-linear-webhook.yaml b/examples/taskspawner-linear-webhook.yaml new file mode 100644 index 00000000..49415a60 --- /dev/null +++ b/examples/taskspawner-linear-webhook.yaml @@ -0,0 +1,145 @@ +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: example-linear-webhook + namespace: default +spec: + when: + linearWebhook: + # Namespace where kelos-webhook-receiver creates WebhookEvent resources + namespace: default + # Optional: filter by Linear issue states + # If not specified, all non-terminal states are accepted (excludes 'completed' and 'canceled') + states: + - "Todo" + - "In Progress" + # Optional: filter by labels (applied client-side to webhook payloads) + labels: + - "kelos-task" + # Optional: exclude items with these labels + excludeLabels: + - "skip-kelos" + + taskTemplate: + type: claude-code + credentials: + type: api-key + secretRef: + name: anthropic-api-key + workspaceRef: + name: my-workspace + promptTemplate: | + {{ .Title }} + + {{ .Body }} + + Please investigate and resolve this {{ .Kind }}. + +--- +# Webhook receiver deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-receiver + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: kelos-webhook-receiver + template: + metadata: + labels: + app: kelos-webhook-receiver + spec: + serviceAccountName: kelos-webhook-receiver + containers: + - name: receiver + image: ghcr.io/kelos-dev/kelos-webhook-receiver:latest + args: + - --namespace=default + - --port=8080 + ports: + - containerPort: 8080 + name: http + env: + # Optional: Linear webhook secret for signature validation + # If not set, signature validation is skipped (development mode only) + - name: LINEAR_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: linear-webhook-secret + key: secret + livenessProbe: + httpGet: + path: /health + port: 8080 + readinessProbe: + httpGet: + path: /health + port: 8080 + +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-receiver + namespace: default +spec: + selector: + app: kelos-webhook-receiver + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + type: LoadBalancer + +--- +# ServiceAccount and RBAC for webhook receiver +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kelos-webhook-receiver + namespace: default + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: kelos-webhook-receiver + namespace: default +rules: + - apiGroups: ["kelos.dev"] + resources: ["webhookevents"] + verbs: ["create", "get", "list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: kelos-webhook-receiver + namespace: default +subjects: + - kind: ServiceAccount + name: kelos-webhook-receiver + namespace: default +roleRef: + kind: Role + name: kelos-webhook-receiver + apiGroup: rbac.authorization.k8s.io + +--- +# Example Secret for Linear webhook signature validation +# Create this with your actual Linear webhook secret: +# +# kubectl create secret generic linear-webhook-secret \ +# --from-literal=secret=your-linear-webhook-secret-here +# +apiVersion: v1 +kind: Secret +metadata: + name: linear-webhook-secret + namespace: default +type: Opaque +stringData: + secret: "your-linear-webhook-secret-here" diff --git a/gemini/Dockerfile b/gemini/Dockerfile index cb1a6b5d..c10f34bc 100644 --- a/gemini/Dockerfile +++ b/gemini/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} COPY gemini/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.gemini && chown -R agent:agent /home/agent diff --git a/internal/cli/install_test.go b/internal/cli/install_test.go index 13bce9e4..34c98d59 100644 --- a/internal/cli/install_test.go +++ b/internal/cli/install_test.go @@ -198,12 +198,12 @@ func TestRenderChart_ImageArgs(t *testing.T) { t.Fatalf("rendering chart: %v", err) } versionedArgs := []string{ - "--claude-code-image=ghcr.io/kelos-dev/claude-code:v0.3.0", - "--codex-image=ghcr.io/kelos-dev/codex:v0.3.0", - "--gemini-image=ghcr.io/kelos-dev/gemini:v0.3.0", - "--opencode-image=ghcr.io/kelos-dev/opencode:v0.3.0", - "--spawner-image=ghcr.io/kelos-dev/kelos-spawner:v0.3.0", - "--token-refresher-image=ghcr.io/kelos-dev/kelos-token-refresher:v0.3.0", + "--claude-code-image=public.ecr.aws/anomalo/kelos/claude-code:v0.3.0", + "--codex-image=public.ecr.aws/anomalo/kelos/codex:v0.3.0", + "--gemini-image=public.ecr.aws/anomalo/kelos/gemini:v0.3.0", + "--opencode-image=public.ecr.aws/anomalo/kelos/opencode:v0.3.0", + "--spawner-image=public.ecr.aws/anomalo/kelos/kelos-spawner:v0.3.0", + "--token-refresher-image=public.ecr.aws/anomalo/kelos/kelos-token-refresher:v0.3.0", } for _, arg := range versionedArgs { if !bytes.Contains(data, []byte(arg)) { diff --git a/internal/controller/job_builder.go b/internal/controller/job_builder.go index 5c3fc7cc..797a622f 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -16,19 +16,19 @@ import ( const ( // ClaudeCodeImage is the default image for Claude Code agent. - ClaudeCodeImage = "ghcr.io/kelos-dev/claude-code:latest" + ClaudeCodeImage = "public.ecr.aws/anomalo/kelos/claude-code:latest" // CodexImage is the default image for OpenAI Codex agent. - CodexImage = "ghcr.io/kelos-dev/codex:latest" + CodexImage = "public.ecr.aws/anomalo/kelos/codex:latest" // GeminiImage is the default image for Google Gemini CLI agent. - GeminiImage = "ghcr.io/kelos-dev/gemini:latest" + GeminiImage = "public.ecr.aws/anomalo/kelos/gemini:latest" // OpenCodeImage is the default image for OpenCode agent. - OpenCodeImage = "ghcr.io/kelos-dev/opencode:latest" + OpenCodeImage = "public.ecr.aws/anomalo/kelos/opencode:latest" // CursorImage is the default image for Cursor CLI agent. - CursorImage = "ghcr.io/kelos-dev/cursor:latest" + CursorImage = "public.ecr.aws/anomalo/kelos/cursor:latest" // AgentTypeClaudeCode is the agent type for Claude Code. AgentTypeClaudeCode = "claude-code" diff --git a/internal/controller/taskspawner_deployment_builder.go b/internal/controller/taskspawner_deployment_builder.go index 1580f4dd..58e14a67 100644 --- a/internal/controller/taskspawner_deployment_builder.go +++ b/internal/controller/taskspawner_deployment_builder.go @@ -17,10 +17,10 @@ import ( const ( // DefaultSpawnerImage is the default image for the spawner binary. - DefaultSpawnerImage = "ghcr.io/kelos-dev/kelos-spawner:latest" + DefaultSpawnerImage = "public.ecr.aws/anomalo/kelos/kelos-spawner:latest" // DefaultTokenRefresherImage is the default image for the token refresher sidecar. - DefaultTokenRefresherImage = "ghcr.io/kelos-dev/kelos-token-refresher:latest" + DefaultTokenRefresherImage = "public.ecr.aws/anomalo/kelos/kelos-token-refresher:latest" // SpawnerServiceAccount is the service account used by spawner Deployments. SpawnerServiceAccount = "kelos-spawner" diff --git a/internal/manifests/charts/kelos/templates/rbac.yaml b/internal/manifests/charts/kelos/templates/rbac.yaml index 2d1a6e1d..0adfef26 100644 --- a/internal/manifests/charts/kelos/templates/rbac.yaml +++ b/internal/manifests/charts/kelos/templates/rbac.yaml @@ -152,6 +152,49 @@ rules: - patch - update - watch + - apiGroups: + - kelos.dev + resources: + - webhookevents + verbs: + - get + - list + - watch + - apiGroups: + - kelos.dev + resources: + - webhookevents/status + verbs: + - get + - update + - patch +{{- if .Values.webhook.enabled }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kelos-webhook-receiver-role +rules: + - apiGroups: + - kelos.dev + resources: + - webhookevents + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kelos-webhook-receiver-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kelos-webhook-receiver-role +subjects: + - kind: ServiceAccount + name: kelos-webhook-receiver + namespace: kelos-system +{{- end }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/internal/manifests/charts/kelos/templates/webhook-receiver.yaml b/internal/manifests/charts/kelos/templates/webhook-receiver.yaml new file mode 100644 index 00000000..a2fb0548 --- /dev/null +++ b/internal/manifests/charts/kelos/templates/webhook-receiver.yaml @@ -0,0 +1,166 @@ +{{- if .Values.webhook.enabled }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kelos-webhook-receiver + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-receiver + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} +spec: + replicas: {{ .Values.webhook.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + template: + metadata: + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + spec: + serviceAccountName: kelos-webhook-receiver + securityContext: + runAsNonRoot: true + containers: + - name: receiver + image: {{ .Values.webhook.image }}{{- if .Values.image.tag }}:{{ .Values.image.tag }}{{- end }} + {{- if .Values.image.pullPolicy }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- end }} + args: + - --namespace={{ .Values.webhook.eventsNamespace }} + - --port={{ .Values.webhook.port }} + ports: + - containerPort: {{ .Values.webhook.port }} + name: http + {{- if or .Values.webhook.githubWebhookSecretName .Values.webhook.extraEnv }} + env: + {{- if .Values.webhook.githubWebhookSecretName }} + - name: GITHUB_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.webhook.githubWebhookSecretName }} + key: secret + {{- end }} + {{- range .Values.webhook.extraEnv }} + - {{ . | toYaml | nindent 14 | trim }} + {{- end }} + {{- end }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + livenessProbe: + httpGet: + path: /health + port: {{ .Values.webhook.port }} + readinessProbe: + httpGet: + path: /health + port: {{ .Values.webhook.port }} + {{- if or .Values.webhook.resources.requests .Values.webhook.resources.limits }} + resources: + {{- if .Values.webhook.resources.limits }} + limits: + {{- range $k, $v := .Values.webhook.resources.limits }} + {{ $k }}: {{ $v }} + {{- end }} + {{- end }} + {{- if .Values.webhook.resources.requests }} + requests: + {{- range $k, $v := .Values.webhook.resources.requests }} + {{ $k }}: {{ $v }} + {{- end }} + {{- end }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-receiver + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} +spec: + selector: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + ports: + - port: {{ .Values.webhook.service.port }} + targetPort: {{ .Values.webhook.port }} + protocol: TCP + type: {{ .Values.webhook.service.type }} +{{- if .Values.webhook.ingress.enabled }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: kelos-webhook-receiver + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- range $k, $v := .Values.webhook.ingress.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- if .Values.webhook.ingress.annotations }} + annotations: + {{- range $k, $v := .Values.webhook.ingress.annotations }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- end }} +spec: + {{- if .Values.webhook.ingress.ingressClassName }} + ingressClassName: {{ .Values.webhook.ingress.ingressClassName }} + {{- end }} + {{- if .Values.webhook.ingress.tls }} + tls: + {{- range .Values.webhook.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + - host: {{ .Values.webhook.ingress.host | quote }} + http: + paths: + - path: {{ .Values.webhook.ingress.path }} + pathType: Prefix + backend: + service: + name: kelos-webhook-receiver + port: + number: {{ .Values.webhook.service.port }} +{{- end }} +{{- end }} diff --git a/internal/manifests/charts/kelos/values.yaml b/internal/manifests/charts/kelos/values.yaml index bc4b9802..0b0cc744 100644 --- a/internal/manifests/charts/kelos/values.yaml +++ b/internal/manifests/charts/kelos/values.yaml @@ -5,19 +5,45 @@ image: telemetry: enabled: true -controllerImage: ghcr.io/kelos-dev/kelos-controller -claudeCodeImage: ghcr.io/kelos-dev/claude-code -codexImage: ghcr.io/kelos-dev/codex -geminiImage: ghcr.io/kelos-dev/gemini -opencodeImage: ghcr.io/kelos-dev/opencode -cursorImage: ghcr.io/kelos-dev/cursor -spawnerImage: ghcr.io/kelos-dev/kelos-spawner +controllerImage: public.ecr.aws/anomalo/kelos/kelos-controller +claudeCodeImage: public.ecr.aws/anomalo/kelos/claude-code +codexImage: public.ecr.aws/anomalo/kelos/codex +geminiImage: public.ecr.aws/anomalo/kelos/gemini +opencodeImage: public.ecr.aws/anomalo/kelos/opencode +cursorImage: public.ecr.aws/anomalo/kelos/cursor +spawnerImage: public.ecr.aws/anomalo/kelos/kelos-spawner spawnerResourceRequests: "" spawnerResourceLimits: "" -tokenRefresherImage: ghcr.io/kelos-dev/kelos-token-refresher +tokenRefresherImage: public.ecr.aws/anomalo/kelos/kelos-token-refresher tokenRefresherResourceRequests: "" tokenRefresherResourceLimits: "" controller: resources: requests: {} limits: {} + +webhook: + enabled: false + labels: {} + image: public.ecr.aws/anomalo/kelos/kelos-webhook-receiver + replicas: 1 + port: 8080 + service: + type: ClusterIP + port: 80 + ingress: + enabled: false + ingressClassName: "" + host: "" + path: / + labels: {} + annotations: {} + tls: [] + eventsNamespace: kelos-system + extraEnv: [] + # Name of an existing Secret containing the GitHub webhook secret + # The secret must have a key named "secret" + githubWebhookSecretName: "" + resources: + requests: {} + limits: {} diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index af40d00d..6d702af4 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -1448,6 +1448,39 @@ spec: rule: '!(has(self.commentPolicy) && ((has(self.triggerComment) && size(self.triggerComment) > 0) || (has(self.excludeComments) && size(self.excludeComments) > 0)))' + githubWebhook: + description: GitHubWebhook discovers issues and pull requests + from GitHub webhooks. + properties: + excludeLabels: + description: ExcludeLabels filters out issues/PRs that have + any of these labels (client-side). + items: + type: string + type: array + labels: + description: Labels filters issues/PRs by labels (applied + client-side to webhook payloads). + items: + type: string + type: array + namespace: + description: |- + Namespace is the Kubernetes namespace where WebhookEvent resources are created. + The spawner will watch for GitHub webhook events in this namespace. + type: string + reporting: + description: Reporting configures status reporting back to + GitHub. + properties: + enabled: + description: Enabled posts standard status comments back + to the originating GitHub issue or PR. + type: boolean + type: object + required: + - namespace + type: object jira: description: Jira discovers issues from a Jira project. properties: @@ -1487,6 +1520,36 @@ spec: - project - secretRef type: object + linearWebhook: + description: LinearWebhook discovers issues from Linear webhooks. + properties: + excludeLabels: + description: ExcludeLabels filters out issues that have any + of these labels (client-side). + items: + type: string + type: array + labels: + description: Labels filters issues by labels (applied client-side + to webhook payloads). + items: + type: string + type: array + namespace: + description: |- + Namespace is the Kubernetes namespace where WebhookEvent resources are created. + The spawner will watch for Linear webhook events in this namespace. + type: string + states: + description: |- + States filters issues by workflow state names (e.g., ["Todo", "In Progress"]). + When empty, all non-terminal states are processed (excludes "Done", "Canceled"). + items: + type: string + type: array + required: + - namespace + type: object type: object required: - taskTemplate @@ -1600,6 +1663,98 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: webhookevents.kelos.dev +spec: + group: kelos.dev + names: + kind: WebhookEvent + listKind: WebhookEventList + plural: webhookevents + singular: webhookevent + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.source + name: Source + type: string + - jsonPath: .status.processed + name: Processed + type: boolean + - jsonPath: .spec.receivedAt + name: ReceivedAt + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: WebhookEvent is the Schema for the webhookevents API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WebhookEventSpec defines the desired state of WebhookEvent. + properties: + payload: + description: Payload is the raw webhook payload as JSON bytes. + format: byte + type: string + receivedAt: + description: ReceivedAt is the timestamp when the webhook was received. + format: date-time + type: string + source: + description: Source is the webhook source type (e.g., "github", "slack", + "linear"). + minLength: 1 + type: string + required: + - payload + - receivedAt + - source + type: object + status: + description: WebhookEventStatus defines the observed state of WebhookEvent. + properties: + message: + description: Message provides additional information about processing. + type: string + processed: + description: Processed indicates whether this event has been processed + by a source. + type: boolean + processedAt: + description: ProcessedAt is the timestamp when the event was processed. + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.20.0 diff --git a/internal/source/github_webhook.go b/internal/source/github_webhook.go new file mode 100644 index 00000000..2e6dde0d --- /dev/null +++ b/internal/source/github_webhook.go @@ -0,0 +1,211 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +// GitHubWebhookSource discovers work items from GitHub webhook events stored +// as WebhookEvent custom resources. This replaces polling the GitHub API with +// push-based webhook notifications. +type GitHubWebhookSource struct { + Client client.Client + Namespace string + + // Labels filters issues/PRs by labels (applied client-side to webhook payloads) + Labels []string + // ExcludeLabels filters out items with these labels (applied client-side) + ExcludeLabels []string +} + +// GitHubWebhookPayload represents the relevant fields from a GitHub webhook payload. +// This handles both issue and pull_request events. +type GitHubWebhookPayload struct { + Action string `json:"action"` // "opened", "reopened", "labeled", etc. + Issue *struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + State string `json:"state"` // "open" or "closed" + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + } `json:"issue,omitempty"` + PullRequest *struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + State string `json:"state"` // "open" or "closed" + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Head struct { + Ref string `json:"ref"` // branch name + } `json:"head"` + } `json:"pull_request,omitempty"` +} + +// Discover fetches unprocessed GitHub webhook events and converts them to WorkItems. +func (s *GitHubWebhookSource) Discover(ctx context.Context) ([]WorkItem, error) { + var eventList kelosv1alpha1.WebhookEventList + + // List all webhook events in namespace + // Field selectors are not supported by fake clients in tests, so filter client-side + if err := s.Client.List(ctx, &eventList, + client.InNamespace(s.Namespace), + ); err != nil { + return nil, fmt.Errorf("listing webhook events: %w", err) + } + + var items []WorkItem + + for i := range eventList.Items { + event := eventList.Items[i].DeepCopy() + + // Filter by source and processed status client-side + if event.Spec.Source != "github" || event.Status.Processed { + continue + } + + // Parse webhook payload + var payload GitHubWebhookPayload + if err := json.Unmarshal(event.Spec.Payload, &payload); err != nil { + // Skip malformed payloads + continue + } + + // Convert to WorkItem + item, ok := s.payloadToWorkItem(payload) + if !ok { + // Mark event as processed even if payload couldn't be converted + event.Status.Processed = true + now := metav1.Now() + event.Status.ProcessedAt = &now + _ = s.Client.Status().Update(ctx, event) + continue + } + + // Apply label filters + if !s.matchesLabels(item.Labels) { + // Mark event as processed even if it was filtered out + event.Status.Processed = true + now := metav1.Now() + event.Status.ProcessedAt = &now + _ = s.Client.Status().Update(ctx, event) + continue + } + + items = append(items, item) + + // Mark event as processed + event.Status.Processed = true + now := metav1.Now() + event.Status.ProcessedAt = &now + if err := s.Client.Status().Update(ctx, event); err != nil { + // Log but continue with other events + continue + } + } + + return items, nil +} + +// payloadToWorkItem converts a GitHub webhook payload to a WorkItem. +// Returns false if the payload should be skipped. +func (s *GitHubWebhookSource) payloadToWorkItem(payload GitHubWebhookPayload) (WorkItem, bool) { + // Handle issue webhooks + if payload.Issue != nil { + issue := payload.Issue + + // Only process open issues + if issue.State != "open" { + return WorkItem{}, false + } + + labels := make([]string, len(issue.Labels)) + for i, l := range issue.Labels { + labels[i] = l.Name + } + + return WorkItem{ + ID: fmt.Sprintf("issue-%d", issue.Number), + Number: issue.Number, + Title: issue.Title, + Body: issue.Body, + URL: issue.HTMLURL, + Labels: labels, + Kind: "Issue", + }, true + } + + // Handle pull request webhooks + if payload.PullRequest != nil { + pr := payload.PullRequest + + // Only process open PRs + if pr.State != "open" { + return WorkItem{}, false + } + + labels := make([]string, len(pr.Labels)) + for i, l := range pr.Labels { + labels[i] = l.Name + } + + return WorkItem{ + ID: fmt.Sprintf("pr-%d", pr.Number), + Number: pr.Number, + Title: pr.Title, + Body: pr.Body, + URL: pr.HTMLURL, + Labels: labels, + Kind: "PR", + Branch: pr.Head.Ref, + }, true + } + + return WorkItem{}, false +} + +// matchesLabels returns true if the item matches the configured label filters. +func (s *GitHubWebhookSource) matchesLabels(itemLabels []string) bool { + // Check required labels (if configured) + if len(s.Labels) > 0 { + hasAllRequired := true + for _, required := range s.Labels { + found := false + for _, label := range itemLabels { + if label == required { + found = true + break + } + } + if !found { + hasAllRequired = false + break + } + } + if !hasAllRequired { + return false + } + } + + // Check excluded labels + for _, excluded := range s.ExcludeLabels { + for _, label := range itemLabels { + if label == excluded { + return false + } + } + } + + return true +} diff --git a/internal/source/github_webhook_test.go b/internal/source/github_webhook_test.go new file mode 100644 index 00000000..951261b4 --- /dev/null +++ b/internal/source/github_webhook_test.go @@ -0,0 +1,278 @@ +package source + +import ( + "context" + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +func TestGitHubWebhookSource_ParseIssuePayload(t *testing.T) { + payload := []byte(`{ + "action": "opened", + "issue": { + "number": 123, + "title": "Test Issue", + "body": "Issue body", + "html_url": "https://github.com/test/repo/issues/123", + "state": "open", + "labels": [ + {"name": "bug"}, + {"name": "kelos-task"} + ] + } + }`) + + var parsed GitHubWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &GitHubWebhookSource{} + item, ok := source.payloadToWorkItem(parsed) + + if !ok { + t.Fatal("Expected payload to be converted to WorkItem") + } + + if item.Number != 123 { + t.Errorf("Expected number 123, got %d", item.Number) + } + if item.Title != "Test Issue" { + t.Errorf("Expected title 'Test Issue', got %s", item.Title) + } + if item.Kind != "Issue" { + t.Errorf("Expected kind 'Issue', got %s", item.Kind) + } + if len(item.Labels) != 2 { + t.Errorf("Expected 2 labels, got %d", len(item.Labels)) + } +} + +func TestGitHubWebhookSource_ParsePullRequestPayload(t *testing.T) { + payload := []byte(`{ + "action": "opened", + "pull_request": { + "number": 456, + "title": "Test PR", + "body": "PR body", + "html_url": "https://github.com/test/repo/pull/456", + "state": "open", + "labels": [ + {"name": "enhancement"} + ], + "head": { + "ref": "feature-branch" + } + } + }`) + + var parsed GitHubWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &GitHubWebhookSource{} + item, ok := source.payloadToWorkItem(parsed) + + if !ok { + t.Fatal("Expected payload to be converted to WorkItem") + } + + if item.Number != 456 { + t.Errorf("Expected number 456, got %d", item.Number) + } + if item.Kind != "PR" { + t.Errorf("Expected kind 'PR', got %s", item.Kind) + } + if item.Branch != "feature-branch" { + t.Errorf("Expected branch 'feature-branch', got %s", item.Branch) + } +} + +func TestGitHubWebhookSource_SkipClosedIssues(t *testing.T) { + payload := []byte(`{ + "action": "closed", + "issue": { + "number": 123, + "title": "Closed Issue", + "state": "closed" + } + }`) + + var parsed GitHubWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &GitHubWebhookSource{} + _, ok := source.payloadToWorkItem(parsed) + + if ok { + t.Error("Expected closed issue to be skipped") + } +} + +func TestGitHubWebhookSource_LabelFiltering(t *testing.T) { + tests := []struct { + name string + itemLabels []string + requiredLabels []string + excludeLabels []string + expectedMatch bool + }{ + { + name: "No filters - matches", + itemLabels: []string{"bug"}, + requiredLabels: nil, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required label present", + itemLabels: []string{"bug", "kelos-task"}, + requiredLabels: []string{"kelos-task"}, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required label missing", + itemLabels: []string{"bug"}, + requiredLabels: []string{"kelos-task"}, + excludeLabels: nil, + expectedMatch: false, + }, + { + name: "Excluded label present", + itemLabels: []string{"bug", "skip"}, + requiredLabels: nil, + excludeLabels: []string{"skip"}, + expectedMatch: false, + }, + { + name: "Multiple required labels", + itemLabels: []string{"bug", "kelos-task", "high-priority"}, + requiredLabels: []string{"kelos-task", "high-priority"}, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required present but also excluded", + itemLabels: []string{"kelos-task", "skip"}, + requiredLabels: []string{"kelos-task"}, + excludeLabels: []string{"skip"}, + expectedMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := &GitHubWebhookSource{ + Labels: tt.requiredLabels, + ExcludeLabels: tt.excludeLabels, + } + + matches := source.matchesLabels(tt.itemLabels) + if matches != tt.expectedMatch { + t.Errorf("Expected match=%v, got %v", tt.expectedMatch, matches) + } + }) + } +} + +func TestGitHubWebhookSource_Discover(t *testing.T) { + scheme := runtime.NewScheme() + _ = kelosv1alpha1.AddToScheme(scheme) + + // Create fake client with webhook events + event1 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "event-1", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 100, + "title": "First Issue", + "body": "Body", + "html_url": "https://github.com/test/repo/issues/100", + "state": "open", + "labels": [{"name": "bug"}] + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + event2 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "event-2", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 200, + "title": "Second Issue", + "state": "closed" + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(event1, event2). + WithStatusSubresource(&kelosv1alpha1.WebhookEvent{}). + Build() + + source := &GitHubWebhookSource{ + Client: fakeClient, + Namespace: "default", + } + + items, err := source.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover failed: %v", err) + } + + // Should only get event1 (event2 has closed issue) + if len(items) != 1 { + t.Errorf("Expected 1 item, got %d", len(items)) + } + + if len(items) > 0 && items[0].Number != 100 { + t.Errorf("Expected issue 100, got %d", items[0].Number) + } + + // Verify events were marked as processed + var updatedEvent kelosv1alpha1.WebhookEvent + if err := fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "event-1", + Namespace: "default", + }, &updatedEvent); err != nil { + t.Fatalf("Failed to get updated event: %v", err) + } + + if !updatedEvent.Status.Processed { + t.Error("Expected event to be marked as processed") + } +} diff --git a/internal/source/linear_webhook.go b/internal/source/linear_webhook.go new file mode 100644 index 00000000..86dc1c23 --- /dev/null +++ b/internal/source/linear_webhook.go @@ -0,0 +1,209 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +// LinearWebhookSource discovers work items from Linear webhook events stored +// as WebhookEvent custom resources. +type LinearWebhookSource struct { + Client client.Client + Namespace string + + // States filters issues by workflow state names (e.g., ["Todo", "In Progress"]) + // When empty, all non-terminal states are processed (excludes "Done", "Canceled") + States []string + // Labels filters issues by labels (applied client-side to webhook payloads) + Labels []string + // ExcludeLabels filters out items with these labels (applied client-side) + ExcludeLabels []string +} + +// LinearWebhookPayload represents the relevant fields from a Linear webhook payload. +type LinearWebhookPayload struct { + Type string `json:"type"` // "Issue" or "Comment" + Action string `json:"action"` // "create", "update", "remove" + Data struct { + ID string `json:"id"` + Identifier string `json:"identifier"` // "TEAM-123" format + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + State struct { + Name string `json:"name"` // "Todo", "In Progress", "Done", etc. + Type string `json:"type"` // "triage", "backlog", "unstarted", "started", "completed", "canceled" + } `json:"state"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Team struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"team"` + } `json:"data"` +} + +// Discover fetches unprocessed Linear webhook events and converts them to WorkItems. +func (s *LinearWebhookSource) Discover(ctx context.Context) ([]WorkItem, error) { + var eventList kelosv1alpha1.WebhookEventList + + // List all webhook events in namespace + if err := s.Client.List(ctx, &eventList, + client.InNamespace(s.Namespace), + ); err != nil { + return nil, fmt.Errorf("listing webhook events: %w", err) + } + + var items []WorkItem + + for i := range eventList.Items { + event := eventList.Items[i].DeepCopy() + + // Filter by source and processed status client-side + if event.Spec.Source != "linear" || event.Status.Processed { + continue + } + + // Parse webhook payload + var payload LinearWebhookPayload + if err := json.Unmarshal(event.Spec.Payload, &payload); err != nil { + // Mark event as processed even if payload is malformed + s.markProcessed(ctx, event) + continue + } + + // Convert to WorkItem + item, ok := s.payloadToWorkItem(payload) + if !ok { + // Mark event as processed even if it can't be converted + s.markProcessed(ctx, event) + continue + } + + // Apply filters + if !s.matchesFilters(item, payload) { + // Mark event as processed even if filtered out + s.markProcessed(ctx, event) + continue + } + + items = append(items, item) + + // Mark event as processed + s.markProcessed(ctx, event) + } + + return items, nil +} + +// payloadToWorkItem converts a Linear webhook payload to a WorkItem. +// Returns false if the payload should be skipped. +func (s *LinearWebhookSource) payloadToWorkItem(payload LinearWebhookPayload) (WorkItem, bool) { + // Only process Issue events + if payload.Type != "Issue" { + return WorkItem{}, false + } + + // Only process create and update actions + if payload.Action != "create" && payload.Action != "update" { + return WorkItem{}, false + } + + // Skip if no data + if payload.Data.Identifier == "" { + return WorkItem{}, false + } + + // Extract labels + labels := make([]string, len(payload.Data.Labels)) + for i, l := range payload.Data.Labels { + labels[i] = l.Name + } + + return WorkItem{ + ID: payload.Data.Identifier, // e.g., "ENG-42" + Number: payload.Data.Number, + Title: payload.Data.Title, + Body: payload.Data.Description, + URL: payload.Data.URL, + Labels: labels, + Kind: payload.Data.State.Name, // e.g., "Todo", "In Progress" + }, true +} + +// matchesFilters returns true if the item matches the configured filters. +func (s *LinearWebhookSource) matchesFilters(item WorkItem, payload LinearWebhookPayload) bool { + // Check state filter + if !s.matchesState(payload.Data.State) { + return false + } + + // Check required labels + if len(s.Labels) > 0 { + hasAllRequired := true + for _, required := range s.Labels { + found := false + for _, label := range item.Labels { + if label == required { + found = true + break + } + } + if !found { + hasAllRequired = false + break + } + } + if !hasAllRequired { + return false + } + } + + // Check excluded labels + for _, excluded := range s.ExcludeLabels { + for _, label := range item.Labels { + if label == excluded { + return false + } + } + } + + return true +} + +// matchesState returns true if the issue state matches the configured state filter. +func (s *LinearWebhookSource) matchesState(state struct { + Name string `json:"name"` + Type string `json:"type"` +}) bool { + // If no states configured, exclude terminal states by default + if len(s.States) == 0 { + // Terminal states: completed, canceled + return state.Type != "completed" && state.Type != "canceled" + } + + // Check if state name is in the configured list + for _, allowed := range s.States { + if state.Name == allowed { + return true + } + } + + return false +} + +// markProcessed marks an event as processed. +func (s *LinearWebhookSource) markProcessed(ctx context.Context, event *kelosv1alpha1.WebhookEvent) { + event.Status.Processed = true + now := metav1.Now() + event.Status.ProcessedAt = &now + _ = s.Client.Status().Update(ctx, event) +} diff --git a/internal/source/linear_webhook_test.go b/internal/source/linear_webhook_test.go new file mode 100644 index 00000000..27a0188c --- /dev/null +++ b/internal/source/linear_webhook_test.go @@ -0,0 +1,433 @@ +package source + +import ( + "context" + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +func TestLinearWebhookSource_ParseIssuePayload(t *testing.T) { + payload := []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "id": "abc-123", + "identifier": "ENG-42", + "number": 42, + "title": "Fix login bug", + "description": "Users cannot log in after password reset", + "url": "https://linear.app/myteam/issue/ENG-42", + "state": { + "name": "Todo", + "type": "unstarted" + }, + "labels": [ + {"name": "bug"}, + {"name": "high-priority"} + ], + "team": { + "key": "ENG", + "name": "Engineering" + } + } + }`) + + var parsed LinearWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &LinearWebhookSource{} + item, ok := source.payloadToWorkItem(parsed) + + if !ok { + t.Fatal("Expected payload to be converted to WorkItem") + } + + if item.ID != "ENG-42" { + t.Errorf("Expected ID 'ENG-42', got %s", item.ID) + } + if item.Number != 42 { + t.Errorf("Expected number 42, got %d", item.Number) + } + if item.Title != "Fix login bug" { + t.Errorf("Expected title 'Fix login bug', got %s", item.Title) + } + if item.Kind != "Todo" { + t.Errorf("Expected kind 'Todo', got %s", item.Kind) + } + if len(item.Labels) != 2 { + t.Errorf("Expected 2 labels, got %d", len(item.Labels)) + } +} + +func TestLinearWebhookSource_SkipNonIssueEvents(t *testing.T) { + payload := []byte(`{ + "type": "Comment", + "action": "create", + "data": { + "body": "This is a comment" + } + }`) + + var parsed LinearWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &LinearWebhookSource{} + _, ok := source.payloadToWorkItem(parsed) + + if ok { + t.Error("Expected Comment event to be skipped") + } +} + +func TestLinearWebhookSource_SkipRemoveAction(t *testing.T) { + payload := []byte(`{ + "type": "Issue", + "action": "remove", + "data": { + "identifier": "ENG-42", + "number": 42, + "title": "Deleted Issue" + } + }`) + + var parsed LinearWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &LinearWebhookSource{} + _, ok := source.payloadToWorkItem(parsed) + + if ok { + t.Error("Expected remove action to be skipped") + } +} + +func TestLinearWebhookSource_StateFiltering(t *testing.T) { + tests := []struct { + name string + states []string + stateName string + stateType string + expectedMatch bool + }{ + { + name: "No filter - accepts non-terminal state", + states: nil, + stateName: "In Progress", + stateType: "started", + expectedMatch: true, + }, + { + name: "No filter - excludes completed", + states: nil, + stateName: "Done", + stateType: "completed", + expectedMatch: false, + }, + { + name: "No filter - excludes canceled", + states: nil, + stateName: "Canceled", + stateType: "canceled", + expectedMatch: false, + }, + { + name: "Filter matches state name", + states: []string{"Todo", "In Progress"}, + stateName: "Todo", + stateType: "unstarted", + expectedMatch: true, + }, + { + name: "Filter does not match state name", + states: []string{"Todo"}, + stateName: "In Progress", + stateType: "started", + expectedMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := &LinearWebhookSource{ + States: tt.states, + } + + state := struct { + Name string `json:"name"` + Type string `json:"type"` + }{ + Name: tt.stateName, + Type: tt.stateType, + } + + matches := source.matchesState(state) + if matches != tt.expectedMatch { + t.Errorf("Expected match=%v, got %v", tt.expectedMatch, matches) + } + }) + } +} + +func TestLinearWebhookSource_LabelFiltering(t *testing.T) { + tests := []struct { + name string + itemLabels []string + requiredLabels []string + excludeLabels []string + expectedMatch bool + }{ + { + name: "No filters - matches", + itemLabels: []string{"bug"}, + requiredLabels: nil, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required label present", + itemLabels: []string{"bug", "high-priority"}, + requiredLabels: []string{"high-priority"}, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required label missing", + itemLabels: []string{"bug"}, + requiredLabels: []string{"high-priority"}, + excludeLabels: nil, + expectedMatch: false, + }, + { + name: "Excluded label present", + itemLabels: []string{"bug", "wont-fix"}, + requiredLabels: nil, + excludeLabels: []string{"wont-fix"}, + expectedMatch: false, + }, + { + name: "Multiple required labels", + itemLabels: []string{"bug", "high-priority", "backend"}, + requiredLabels: []string{"high-priority", "backend"}, + excludeLabels: nil, + expectedMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := &LinearWebhookSource{ + Labels: tt.requiredLabels, + ExcludeLabels: tt.excludeLabels, + } + + item := WorkItem{Labels: tt.itemLabels} + payload := LinearWebhookPayload{} + payload.Data.State.Type = "unstarted" // Non-terminal state + + matches := source.matchesFilters(item, payload) + if matches != tt.expectedMatch { + t.Errorf("Expected match=%v, got %v", tt.expectedMatch, matches) + } + }) + } +} + +func TestLinearWebhookSource_Discover(t *testing.T) { + scheme := runtime.NewScheme() + _ = kelosv1alpha1.AddToScheme(scheme) + + // Create fake client with webhook events + event1 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "event-1", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-100", + "number": 100, + "title": "First Issue", + "description": "Test description", + "url": "https://linear.app/myteam/issue/ENG-100", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [{"name": "bug"}], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + event2 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "event-2", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "update", + "data": { + "identifier": "ENG-200", + "number": 200, + "title": "Second Issue", + "state": {"name": "Done", "type": "completed"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(event1, event2). + WithStatusSubresource(&kelosv1alpha1.WebhookEvent{}). + Build() + + source := &LinearWebhookSource{ + Client: fakeClient, + Namespace: "default", + } + + items, err := source.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover failed: %v", err) + } + + // Should only get event1 (event2 has completed state) + if len(items) != 1 { + t.Errorf("Expected 1 item, got %d", len(items)) + } + + if len(items) > 0 && items[0].Number != 100 { + t.Errorf("Expected issue 100, got %d", items[0].Number) + } + + // Verify events were marked as processed + var updatedEvent kelosv1alpha1.WebhookEvent + if err := fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "event-1", + Namespace: "default", + }, &updatedEvent); err != nil { + t.Fatalf("Failed to get updated event: %v", err) + } + + if !updatedEvent.Status.Processed { + t.Error("Expected event to be marked as processed") + } +} + +func TestLinearWebhookSource_OnlyProcessLinearSource(t *testing.T) { + scheme := runtime.NewScheme() + _ = kelosv1alpha1.AddToScheme(scheme) + + // Create a GitHub webhook event (should be ignored) + githubEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "github-event", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": {"number": 1} + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + // Create a Linear webhook event + linearEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "linear-event", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-300", + "number": 300, + "title": "Linear Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(githubEvent, linearEvent). + WithStatusSubresource(&kelosv1alpha1.WebhookEvent{}). + Build() + + source := &LinearWebhookSource{ + Client: fakeClient, + Namespace: "default", + } + + items, err := source.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover failed: %v", err) + } + + // Should only get the Linear event + if len(items) != 1 { + t.Errorf("Expected 1 item, got %d", len(items)) + } + + if len(items) > 0 && items[0].Number != 300 { + t.Errorf("Expected issue 300, got %d", items[0].Number) + } + + // Verify GitHub event was not processed + var githubUpdated kelosv1alpha1.WebhookEvent + if err := fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "github-event", + Namespace: "default", + }, &githubUpdated); err != nil { + t.Fatalf("Failed to get GitHub event: %v", err) + } + + if githubUpdated.Status.Processed { + t.Error("Expected GitHub event to not be processed by Linear source") + } +} diff --git a/local-run.sh b/local-run.sh index 3584639b..1427e4f6 100755 --- a/local-run.sh +++ b/local-run.sh @@ -5,7 +5,7 @@ set -o nounset set -o pipefail KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" -REGISTRY="${REGISTRY:-ghcr.io/kelos-dev}" +REGISTRY="${REGISTRY:-public.ecr.aws/anomalo/kelos}" LOCAL_IMAGE_TAG="${LOCAL_IMAGE_TAG:-local-dev}" if ! command -v kind >/dev/null 2>&1; then echo "Kind CLI not found in PATH" >&2 diff --git a/opencode/Dockerfile b/opencode/Dockerfile index f1cb13fb..f877d97a 100644 --- a/opencode/Dockerfile +++ b/opencode/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g opencode-ai@${OPENCODE_VERSION} COPY opencode/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.opencode && chown -R agent:agent /home/agent diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/api_client.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/api_client.go index d1460567..f82097cf 100644 --- a/pkg/generated/clientset/versioned/typed/api/v1alpha1/api_client.go +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/api_client.go @@ -31,6 +31,7 @@ type ApiV1alpha1Interface interface { AgentConfigsGetter TasksGetter TaskSpawnersGetter + WebhookEventsGetter WorkspacesGetter } @@ -51,6 +52,10 @@ func (c *ApiV1alpha1Client) TaskSpawners(namespace string) TaskSpawnerInterface return newTaskSpawners(c, namespace) } +func (c *ApiV1alpha1Client) WebhookEvents(namespace string) WebhookEventInterface { + return newWebhookEvents(c, namespace) +} + func (c *ApiV1alpha1Client) Workspaces(namespace string) WorkspaceInterface { return newWorkspaces(c, namespace) } diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go index bb808159..b08ae727 100644 --- a/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go @@ -40,6 +40,10 @@ func (c *FakeApiV1alpha1) TaskSpawners(namespace string) v1alpha1.TaskSpawnerInt return newFakeTaskSpawners(c, namespace) } +func (c *FakeApiV1alpha1) WebhookEvents(namespace string) v1alpha1.WebhookEventInterface { + return newFakeWebhookEvents(c, namespace) +} + func (c *FakeApiV1alpha1) Workspaces(namespace string) v1alpha1.WorkspaceInterface { return newFakeWorkspaces(c, namespace) } diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_webhookevent.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_webhookevent.go new file mode 100644 index 00000000..72fdd1fc --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_webhookevent.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 Gunju Kim + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + apiv1alpha1 "github.com/kelos-dev/kelos/pkg/generated/clientset/versioned/typed/api/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeWebhookEvents implements WebhookEventInterface +type fakeWebhookEvents struct { + *gentype.FakeClientWithList[*v1alpha1.WebhookEvent, *v1alpha1.WebhookEventList] + Fake *FakeApiV1alpha1 +} + +func newFakeWebhookEvents(fake *FakeApiV1alpha1, namespace string) apiv1alpha1.WebhookEventInterface { + return &fakeWebhookEvents{ + gentype.NewFakeClientWithList[*v1alpha1.WebhookEvent, *v1alpha1.WebhookEventList]( + fake.Fake, + namespace, + v1alpha1.SchemeGroupVersion.WithResource("webhookevents"), + v1alpha1.SchemeGroupVersion.WithKind("WebhookEvent"), + func() *v1alpha1.WebhookEvent { return &v1alpha1.WebhookEvent{} }, + func() *v1alpha1.WebhookEventList { return &v1alpha1.WebhookEventList{} }, + func(dst, src *v1alpha1.WebhookEventList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.WebhookEventList) []*v1alpha1.WebhookEvent { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.WebhookEventList, items []*v1alpha1.WebhookEvent) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go index 57b3ddd3..d67e10b8 100644 --- a/pkg/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go @@ -24,4 +24,6 @@ type TaskExpansion interface{} type TaskSpawnerExpansion interface{} +type WebhookEventExpansion interface{} + type WorkspaceExpansion interface{} diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/webhookevent.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/webhookevent.go new file mode 100644 index 00000000..5529d03d --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/webhookevent.go @@ -0,0 +1,70 @@ +/* +Copyright 2026 Gunju Kim + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + apiv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + scheme "github.com/kelos-dev/kelos/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// WebhookEventsGetter has a method to return a WebhookEventInterface. +// A group's client should implement this interface. +type WebhookEventsGetter interface { + WebhookEvents(namespace string) WebhookEventInterface +} + +// WebhookEventInterface has methods to work with WebhookEvent resources. +type WebhookEventInterface interface { + Create(ctx context.Context, webhookEvent *apiv1alpha1.WebhookEvent, opts v1.CreateOptions) (*apiv1alpha1.WebhookEvent, error) + Update(ctx context.Context, webhookEvent *apiv1alpha1.WebhookEvent, opts v1.UpdateOptions) (*apiv1alpha1.WebhookEvent, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, webhookEvent *apiv1alpha1.WebhookEvent, opts v1.UpdateOptions) (*apiv1alpha1.WebhookEvent, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha1.WebhookEvent, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.WebhookEventList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha1.WebhookEvent, err error) + WebhookEventExpansion +} + +// webhookEvents implements WebhookEventInterface +type webhookEvents struct { + *gentype.ClientWithList[*apiv1alpha1.WebhookEvent, *apiv1alpha1.WebhookEventList] +} + +// newWebhookEvents returns a WebhookEvents +func newWebhookEvents(c *ApiV1alpha1Client, namespace string) *webhookEvents { + return &webhookEvents{ + gentype.NewClientWithList[*apiv1alpha1.WebhookEvent, *apiv1alpha1.WebhookEventList]( + "webhookevents", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *apiv1alpha1.WebhookEvent { return &apiv1alpha1.WebhookEvent{} }, + func() *apiv1alpha1.WebhookEventList { return &apiv1alpha1.WebhookEventList{} }, + ), + } +} diff --git a/pkg/generated/informers/externalversions/api/v1alpha1/interface.go b/pkg/generated/informers/externalversions/api/v1alpha1/interface.go index 7e166f1b..177a8f8b 100644 --- a/pkg/generated/informers/externalversions/api/v1alpha1/interface.go +++ b/pkg/generated/informers/externalversions/api/v1alpha1/interface.go @@ -30,6 +30,8 @@ type Interface interface { Tasks() TaskInformer // TaskSpawners returns a TaskSpawnerInformer. TaskSpawners() TaskSpawnerInformer + // WebhookEvents returns a WebhookEventInformer. + WebhookEvents() WebhookEventInformer // Workspaces returns a WorkspaceInformer. Workspaces() WorkspaceInformer } @@ -60,6 +62,11 @@ func (v *version) TaskSpawners() TaskSpawnerInformer { return &taskSpawnerInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// WebhookEvents returns a WebhookEventInformer. +func (v *version) WebhookEvents() WebhookEventInformer { + return &webhookEventInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // Workspaces returns a WorkspaceInformer. func (v *version) Workspaces() WorkspaceInformer { return &workspaceInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/pkg/generated/informers/externalversions/api/v1alpha1/webhookevent.go b/pkg/generated/informers/externalversions/api/v1alpha1/webhookevent.go new file mode 100644 index 00000000..7492c2cb --- /dev/null +++ b/pkg/generated/informers/externalversions/api/v1alpha1/webhookevent.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 Gunju Kim + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + kelosapiv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + versioned "github.com/kelos-dev/kelos/pkg/generated/clientset/versioned" + internalinterfaces "github.com/kelos-dev/kelos/pkg/generated/informers/externalversions/internalinterfaces" + apiv1alpha1 "github.com/kelos-dev/kelos/pkg/generated/listers/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// WebhookEventInformer provides access to a shared informer and lister for +// WebhookEvents. +type WebhookEventInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.WebhookEventLister +} + +type webhookEventInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewWebhookEventInformer constructs a new informer for WebhookEvent type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewWebhookEventInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredWebhookEventInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredWebhookEventInformer constructs a new informer for WebhookEvent type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredWebhookEventInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApiV1alpha1().WebhookEvents(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApiV1alpha1().WebhookEvents(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApiV1alpha1().WebhookEvents(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApiV1alpha1().WebhookEvents(namespace).Watch(ctx, options) + }, + }, client), + &kelosapiv1alpha1.WebhookEvent{}, + resyncPeriod, + indexers, + ) +} + +func (f *webhookEventInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredWebhookEventInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *webhookEventInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&kelosapiv1alpha1.WebhookEvent{}, f.defaultInformer) +} + +func (f *webhookEventInformer) Lister() apiv1alpha1.WebhookEventLister { + return apiv1alpha1.NewWebhookEventLister(f.Informer().GetIndexer()) +} diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go index 2a4670ac..411a17c0 100644 --- a/pkg/generated/informers/externalversions/generic.go +++ b/pkg/generated/informers/externalversions/generic.go @@ -59,6 +59,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().Tasks().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("taskspawners"): return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().TaskSpawners().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("webhookevents"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().WebhookEvents().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("workspaces"): return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().Workspaces().Informer()}, nil diff --git a/pkg/generated/listers/api/v1alpha1/expansion_generated.go b/pkg/generated/listers/api/v1alpha1/expansion_generated.go index eea188a9..511044fd 100644 --- a/pkg/generated/listers/api/v1alpha1/expansion_generated.go +++ b/pkg/generated/listers/api/v1alpha1/expansion_generated.go @@ -42,6 +42,14 @@ type TaskSpawnerListerExpansion interface{} // TaskSpawnerNamespaceLister. type TaskSpawnerNamespaceListerExpansion interface{} +// WebhookEventListerExpansion allows custom methods to be added to +// WebhookEventLister. +type WebhookEventListerExpansion interface{} + +// WebhookEventNamespaceListerExpansion allows custom methods to be added to +// WebhookEventNamespaceLister. +type WebhookEventNamespaceListerExpansion interface{} + // WorkspaceListerExpansion allows custom methods to be added to // WorkspaceLister. type WorkspaceListerExpansion interface{} diff --git a/pkg/generated/listers/api/v1alpha1/webhookevent.go b/pkg/generated/listers/api/v1alpha1/webhookevent.go new file mode 100644 index 00000000..7ec537c0 --- /dev/null +++ b/pkg/generated/listers/api/v1alpha1/webhookevent.go @@ -0,0 +1,70 @@ +/* +Copyright 2026 Gunju Kim + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + apiv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// WebhookEventLister helps list WebhookEvents. +// All objects returned here must be treated as read-only. +type WebhookEventLister interface { + // List lists all WebhookEvents in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.WebhookEvent, err error) + // WebhookEvents returns an object that can list and get WebhookEvents. + WebhookEvents(namespace string) WebhookEventNamespaceLister + WebhookEventListerExpansion +} + +// webhookEventLister implements the WebhookEventLister interface. +type webhookEventLister struct { + listers.ResourceIndexer[*apiv1alpha1.WebhookEvent] +} + +// NewWebhookEventLister returns a new WebhookEventLister. +func NewWebhookEventLister(indexer cache.Indexer) WebhookEventLister { + return &webhookEventLister{listers.New[*apiv1alpha1.WebhookEvent](indexer, apiv1alpha1.Resource("webhookevent"))} +} + +// WebhookEvents returns an object that can list and get WebhookEvents. +func (s *webhookEventLister) WebhookEvents(namespace string) WebhookEventNamespaceLister { + return webhookEventNamespaceLister{listers.NewNamespaced[*apiv1alpha1.WebhookEvent](s.ResourceIndexer, namespace)} +} + +// WebhookEventNamespaceLister helps list and get WebhookEvents. +// All objects returned here must be treated as read-only. +type WebhookEventNamespaceLister interface { + // List lists all WebhookEvents in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.WebhookEvent, err error) + // Get retrieves the WebhookEvent from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.WebhookEvent, error) + WebhookEventNamespaceListerExpansion +} + +// webhookEventNamespaceLister implements the WebhookEventNamespaceLister +// interface. +type webhookEventNamespaceLister struct { + listers.ResourceIndexer[*apiv1alpha1.WebhookEvent] +} diff --git a/test/integration/linear_webhook_test.go b/test/integration/linear_webhook_test.go new file mode 100644 index 00000000..729d2b60 --- /dev/null +++ b/test/integration/linear_webhook_test.go @@ -0,0 +1,458 @@ +package integration + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/source" +) + +var _ = Describe("Linear Webhook Integration", func() { + const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + Context("When receiving Linear webhook events", func() { + It("Should discover and process WebhookEvent CRDs", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-webhook-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for a Linear issue") + issuePayload := []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "id": "abc-123", + "identifier": "ENG-42", + "number": 42, + "title": "Fix authentication bug", + "description": "Users cannot log in after password reset", + "url": "https://linear.app/myteam/issue/ENG-42", + "state": { + "name": "Todo", + "type": "unstarted" + }, + "labels": [ + {"name": "bug"}, + {"name": "high-priority"} + ], + "team": { + "key": "ENG", + "name": "Engineering" + } + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: issuePayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a LinearWebhookSource") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items from the webhook") + var items []source.WorkItem + Eventually(func() int { + var err error + items, err = webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + return len(items) + }, timeout, interval).Should(Equal(1)) + + By("Verifying the discovered work item") + Expect(items[0].ID).To(Equal("ENG-42")) + Expect(items[0].Number).To(Equal(42)) + Expect(items[0].Title).To(Equal("Fix authentication bug")) + Expect(items[0].Kind).To(Equal("Todo")) + Expect(items[0].Labels).To(ContainElement("bug")) + Expect(items[0].Labels).To(ContainElement("high-priority")) + + By("Verifying the WebhookEvent was marked as processed") + updatedEvent := &kelosv1alpha1.WebhookEvent{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event.Name, + Namespace: ns.Name, + }, updatedEvent) + if err != nil { + return false + } + return updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Expect(updatedEvent.Status.ProcessedAt).NotTo(BeNil()) + }) + + It("Should filter WebhookEvents by state", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-state-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for a completed issue") + completedPayload := []byte(`{ + "type": "Issue", + "action": "update", + "data": { + "identifier": "ENG-100", + "number": 100, + "title": "Completed Issue", + "state": { + "name": "Done", + "type": "completed" + }, + "labels": [], + "team": {"key": "ENG"} + } + }`) + + event1 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: completedPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event1)).Should(Succeed()) + + By("Creating a WebhookEvent for an open issue") + openPayload := []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-200", + "number": 200, + "title": "Open Issue", + "state": { + "name": "In Progress", + "type": "started" + }, + "labels": [], + "team": {"key": "ENG"} + } + }`) + + event2 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: openPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event2)).Should(Succeed()) + + By("Creating a LinearWebhookSource with no state filter") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the non-terminal issue was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(200)) + + By("Verifying both events were marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event1.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event2.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should filter by configured states", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-states-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating WebhookEvents with different states") + todoEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-300", + "number": 300, + "title": "Todo Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, todoEvent)).Should(Succeed()) + + inProgressEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-400", + "number": 400, + "title": "In Progress Issue", + "state": {"name": "In Progress", "type": "started"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, inProgressEvent)).Should(Succeed()) + + By("Creating a LinearWebhookSource with state filter") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + States: []string{"Todo"}, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only Todo state was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(300)) + Expect(items[0].Kind).To(Equal("Todo")) + }) + + It("Should filter by labels", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-labels-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent with matching labels") + matchingEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-500", + "number": 500, + "title": "Bug Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [{"name": "bug"}, {"name": "backend"}], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, matchingEvent)).Should(Succeed()) + + By("Creating a WebhookEvent without matching labels") + nonMatchingEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-600", + "number": 600, + "title": "Feature Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [{"name": "feature"}], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, nonMatchingEvent)).Should(Succeed()) + + By("Creating a LinearWebhookSource with label filter") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + Labels: []string{"bug"}, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the bug-labeled issue was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(500)) + + By("Verifying both events were marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: matchingEvent.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: nonMatchingEvent.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should only process events with source=linear", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-source-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a GitHub webhook event (should be ignored)") + githubEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": {"number": 1} + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, githubEvent)).Should(Succeed()) + + By("Creating a Linear webhook event") + linearEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-700", + "number": 700, + "title": "Linear Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, linearEvent)).Should(Succeed()) + + By("Creating a LinearWebhookSource") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the Linear event was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(700)) + + By("Verifying GitHub event was not processed") + githubUpdated := &kelosv1alpha1.WebhookEvent{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: githubEvent.Name, + Namespace: ns.Name, + }, githubUpdated) + Expect(err).NotTo(HaveOccurred()) + Expect(githubUpdated.Status.Processed).To(BeFalse()) + }) + }) +}) diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index 73d58536..6562f2af 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -117,6 +117,9 @@ var _ = BeforeSuite(func() { Eventually(func() error { return k8sClient.List(ctx, &kelosv1alpha1.WorkspaceList{}) }, 30*time.Second, 100*time.Millisecond).Should(Succeed()) + Eventually(func() error { + return k8sClient.List(ctx, &kelosv1alpha1.WebhookEventList{}) + }, 30*time.Second, 100*time.Millisecond).Should(Succeed()) }) var _ = AfterSuite(func() { diff --git a/test/integration/webhook_test.go b/test/integration/webhook_test.go new file mode 100644 index 00000000..aaed584b --- /dev/null +++ b/test/integration/webhook_test.go @@ -0,0 +1,456 @@ +package integration + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/source" +) + +var _ = Describe("Webhook Integration", func() { + const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + Context("When receiving GitHub webhook events", func() { + It("Should discover and process WebhookEvent CRDs", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for an open issue") + issuePayload := []byte(`{ + "action": "opened", + "issue": { + "number": 42, + "title": "Test Issue", + "body": "This is a test issue", + "html_url": "https://github.com/test/repo/issues/42", + "state": "open", + "labels": [ + {"name": "bug"}, + {"name": "kelos-task"} + ] + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: issuePayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a GitHubWebhookSource") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + Labels: []string{"kelos-task"}, + } + + By("Discovering work items from the webhook") + var items []source.WorkItem + Eventually(func() int { + var err error + items, err = webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + return len(items) + }, timeout, interval).Should(Equal(1)) + + By("Verifying the discovered work item") + Expect(items[0].Number).To(Equal(42)) + Expect(items[0].Title).To(Equal("Test Issue")) + Expect(items[0].Kind).To(Equal("Issue")) + Expect(items[0].Labels).To(ContainElement("bug")) + Expect(items[0].Labels).To(ContainElement("kelos-task")) + + By("Verifying the WebhookEvent was marked as processed") + updatedEvent := &kelosv1alpha1.WebhookEvent{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event.Name, + Namespace: ns.Name, + }, updatedEvent) + if err != nil { + return false + } + return updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Expect(updatedEvent.Status.ProcessedAt).NotTo(BeNil()) + + By("Verifying subsequent discoveries return no items (already processed)") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(items).To(BeEmpty()) + }) + + It("Should filter WebhookEvents by labels", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-filter-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for an issue without required label") + eventWithoutLabel := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 100, + "title": "Issue Without Label", + "state": "open", + "labels": [{"name": "bug"}] + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, eventWithoutLabel)).Should(Succeed()) + + By("Creating a WebhookEvent for an issue with required label") + eventWithLabel := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 200, + "title": "Issue With Label", + "state": "open", + "labels": [ + {"name": "bug"}, + {"name": "kelos-task"} + ] + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, eventWithLabel)).Should(Succeed()) + + By("Creating a GitHubWebhookSource with label filter") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + Labels: []string{"kelos-task"}, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the issue with the required label was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(200)) + + By("Verifying both events were marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: eventWithLabel.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: eventWithoutLabel.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should handle pull request webhooks", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-pr-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for an open pull request") + prPayload := []byte(`{ + "action": "opened", + "pull_request": { + "number": 123, + "title": "Test PR", + "body": "This is a test PR", + "html_url": "https://github.com/test/repo/pull/123", + "state": "open", + "labels": [ + {"name": "enhancement"} + ], + "head": { + "ref": "feature-branch" + } + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: prPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a GitHubWebhookSource") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering the pull request") + var items []source.WorkItem + Eventually(func() int { + var err error + items, err = webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + return len(items) + }, timeout, interval).Should(Equal(1)) + + By("Verifying the discovered work item") + Expect(items[0].Number).To(Equal(123)) + Expect(items[0].Title).To(Equal("Test PR")) + Expect(items[0].Kind).To(Equal("PR")) + Expect(items[0].Branch).To(Equal("feature-branch")) + Expect(items[0].Labels).To(ContainElement("enhancement")) + }) + + It("Should skip closed issues and pull requests", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-closed-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for a closed issue") + closedPayload := []byte(`{ + "action": "closed", + "issue": { + "number": 999, + "title": "Closed Issue", + "state": "closed", + "labels": [] + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: closedPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a GitHubWebhookSource") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying no items were discovered") + Expect(items).To(BeEmpty()) + + By("Verifying the event was still marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should handle excludeLabels filter", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-exclude-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for an issue with excluded label") + excludedPayload := []byte(`{ + "action": "opened", + "issue": { + "number": 300, + "title": "Excluded Issue", + "state": "open", + "labels": [ + {"name": "bug"}, + {"name": "skip"} + ] + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: excludedPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a GitHubWebhookSource with excludeLabels") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + ExcludeLabels: []string{"skip"}, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the issue was filtered out") + Expect(items).To(BeEmpty()) + + By("Verifying the event was still marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should only process events with source=github", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-source-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent with source=slack") + slackEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "slack-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "slack", + Payload: []byte(`{"event": "message"}`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, slackEvent)).Should(Succeed()) + + By("Creating a WebhookEvent with source=github") + githubEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 500, + "title": "GitHub Issue", + "state": "open", + "labels": [] + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, githubEvent)).Should(Succeed()) + + By("Creating a GitHubWebhookSource") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the GitHub event was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(500)) + + By("Verifying only the GitHub event was marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: githubEvent.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + slackUpdated := &kelosv1alpha1.WebhookEvent{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: slackEvent.Name, + Namespace: ns.Name, + }, slackUpdated) + Expect(err).NotTo(HaveOccurred()) + Expect(slackUpdated.Status.Processed).To(BeFalse()) + }) + }) +})