From 5644b0ad18c9da39d029cf3386ec263e96189743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 26 Apr 2026 18:14:41 +0100 Subject: [PATCH] feat(preview): add PR preview environments via Sprites (#707) --- .github/workflows/preview-env.yml | 173 ++++++++++ apps/backend/Makefile | 9 +- apps/backend/cmd/kandev/main.go | 13 +- apps/backend/cmd/preview/build.go | 271 +++++++++++++++ apps/backend/cmd/preview/cleanup.go | 60 ++++ apps/backend/cmd/preview/deploy.go | 157 +++++++++ apps/backend/cmd/preview/github.go | 187 +++++++++++ apps/backend/cmd/preview/github_test.go | 122 +++++++ apps/backend/cmd/preview/main.go | 72 ++++ apps/backend/cmd/preview/sprite_ops.go | 314 ++++++++++++++++++ apps/backend/cmd/preview/sprite_ops_test.go | 20 ++ .../backend/internal/agent/lifecycle/types.go | 6 +- .../settings/controller/agent_discovery.go | 4 +- .../settings/controller/controller_test.go | 19 ++ apps/backend/internal/common/config/config.go | 7 +- apps/backend/internal/common/ports/ports.go | 21 ++ apps/backend/internal/github/service.go | 134 +++++++- apps/backend/internal/github/service_test.go | 158 +++++++++ .../task/service/repository_discovery.go | 31 +- 19 files changed, 1748 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/preview-env.yml create mode 100644 apps/backend/cmd/preview/build.go create mode 100644 apps/backend/cmd/preview/cleanup.go create mode 100644 apps/backend/cmd/preview/deploy.go create mode 100644 apps/backend/cmd/preview/github.go create mode 100644 apps/backend/cmd/preview/github_test.go create mode 100644 apps/backend/cmd/preview/main.go create mode 100644 apps/backend/cmd/preview/sprite_ops.go create mode 100644 apps/backend/cmd/preview/sprite_ops_test.go create mode 100644 apps/backend/internal/common/ports/ports.go diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml new file mode 100644 index 0000000000..cfbd988edf --- /dev/null +++ b/.github/workflows/preview-env.yml @@ -0,0 +1,173 @@ +name: Preview Environment + +# Dual triggers — mirrors the claude-code-review.yml pattern exactly: +# pull_request: same-repo PRs (secrets available, safe to run on PR head) +# pull_request_target: fork PRs (secrets available via base repo, gated by allowlist) +# Exactly one job runs per PR event based on event_name AND head repo identity. +on: + pull_request: + types: [opened, synchronize, reopened, closed] + pull_request_target: + types: [opened, synchronize, reopened, closed] + +jobs: + # ----------------------------------------------------------------------- + # Deploy / update preview (same-repo PRs) + # Always runs for same-repo pushes that are not a close event. + # ----------------------------------------------------------------------- + deploy-same-repo: + if: > + github.event.action != 'closed' && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-go@v6 + with: + go-version-file: apps/backend/go.mod + cache-dependency-path: apps/backend/go.sum + + - uses: pnpm/action-setup@v4 + with: + version: "9" + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install frontend dependencies + run: pnpm -C apps install --frozen-lockfile + + - name: Deploy preview environment + working-directory: apps/backend + run: go run ./cmd/preview deploy --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-web-install + env: + PR: ${{ github.event.pull_request.number }} + SHA: ${{ github.event.pull_request.head.sha }} + REPO: ${{ github.repository }} + SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ----------------------------------------------------------------------- + # Deploy / update preview (fork PRs, gated by allowlist) + # Uses pull_request_target so secrets are available. + # Gated by: OWNER/MEMBER/COLLABORATOR author_association, + # `safe-to-test` label (maintainer opt-in), or + # PREVIEW_ENV_ALLOWLIST repo variable (JSON array of logins). + # ----------------------------------------------------------------------- + deploy-fork: + if: > + github.event.action != 'closed' && + github.event_name == 'pull_request_target' && + github.event.pull_request.head.repo.full_name != github.repository && + (contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) || + contains(github.event.pull_request.labels.*.name, 'safe-to-test') || + (vars.PREVIEW_ENV_ALLOWLIST != '' && contains(fromJSON(vars.PREVIEW_ENV_ALLOWLIST), github.event.pull_request.user.login))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + # pull_request_target checks out base by default; explicitly use PR head. + # Safe: the preview CLI only reads files to build artifacts and posts comments. + # NOTE: The allowlist gate (OWNER/MEMBER/COLLABORATOR, safe-to-test, PREVIEW_ENV_ALLOWLIST) + # authorizes running the fork's cmd/preview code with SPRITES_API_TOKEN and GH_TOKEN. + # The safe-to-test label must be re-applied after each new push (see strip-safe-to-test job). + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-go@v6 + with: + go-version-file: apps/backend/go.mod + cache-dependency-path: apps/backend/go.sum + + - uses: pnpm/action-setup@v4 + with: + version: "9" + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install frontend dependencies + run: pnpm -C apps install --frozen-lockfile + + - name: Deploy preview environment + working-directory: apps/backend + run: go run ./cmd/preview deploy --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-web-install + env: + PR: ${{ github.event.pull_request.number }} + SHA: ${{ github.event.pull_request.head.sha }} + REPO: ${{ github.repository }} + SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ----------------------------------------------------------------------- + # Strip safe-to-test label on new pushes to fork PRs. + # Prevents a once-approved fork from running updated (potentially malicious) + # code on subsequent pushes without a fresh maintainer re-approval. + # ----------------------------------------------------------------------- + strip-safe-to-test: + if: > + github.event_name == 'pull_request_target' && + github.event.action == 'synchronize' && + github.event.pull_request.head.repo.full_name != github.repository && + contains(github.event.pull_request.labels.*.name, 'safe-to-test') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + name: 'safe-to-test', + }); + + # ----------------------------------------------------------------------- + # Cleanup (all closed PRs — no allowlist check) + # The sprite must be destroyed regardless of how the PR was closed or + # whether a preview was ever successfully deployed. Handles gracefully + # if the sprite does not exist (was never deployed or already destroyed). + # Runs only on pull_request_target to avoid duplicate runs — both events + # fire `closed`, but only pull_request_target has secrets for all PRs. + # ----------------------------------------------------------------------- + cleanup-preview: + if: > + github.event.action == 'closed' && + github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v6 + with: + go-version-file: apps/backend/go.mod + cache-dependency-path: apps/backend/go.sum + + - name: Destroy preview environment + working-directory: apps/backend + run: go run ./cmd/preview cleanup --pr "$PR" --repo "$REPO" + env: + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/backend/Makefile b/apps/backend/Makefile index b0c9a636d6..54dbe60b49 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -25,11 +25,17 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) -.PHONY: all build build-all build-agentctl build-agentctl-linux build-acpdbg acpdbg build-mock-agent clean run dev start-debug test test-e2e test-sprites-e2e lint fmt vet help +.PHONY: all build build-all build-agentctl build-agentctl-linux build-acpdbg acpdbg build-mock-agent build-preview clean run dev start-debug test test-e2e test-sprites-e2e lint fmt vet help ## Default target all: build +## Build the preview CLI for deploying PR preview environments to Sprites +build-preview: + @echo "Building preview..." + @$(MKDIR) $(BUILD_DIR) + $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/preview ./cmd/preview + ## Build the mock-agent binary for testing build-mock-agent: @echo "Building mock-agent..." @@ -162,6 +168,7 @@ help: @echo " build-all Build all binaries (unified + individual services + agentctl)" @echo " build-agentctl Build the agentctl sidecar binary (includes embedded MCP server)" @echo " build-mock-agent Build the mock-agent binary for testing" + @echo " build-preview Build the preview CLI for PR preview environments" @echo " clean Remove build artifacts" @echo " run Build and run the application" @echo " dev Run in development mode (pprof enabled)" diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index cbb6b8a10d..b30e972953 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -60,16 +60,13 @@ import ( // Database "github.com/kandev/kandev/internal/db" -) -// defaultServerPort is the fallback HTTP port when neither the CLI flag nor -// the config file / env var sets one. Kept in sync with the viper default in -// internal/common/config.setDefaults. -const defaultServerPort = 38429 + "github.com/kandev/kandev/internal/common/ports" +) // Command-line flags var ( - flagPort = flag.Int("port", 0, fmt.Sprintf("HTTP server port (default: %d)", defaultServerPort)) + flagPort = flag.Int("port", 0, fmt.Sprintf("HTTP server port (default: %d)", ports.Backend)) flagLogLevel = flag.String("log-level", "", "Log level: debug, info, warn, error") flagHelp = flag.Bool("help", false, "Show help message") flagVersion = flag.Bool("version", false, "Show version information") @@ -435,7 +432,7 @@ func startGatewayAndServe( port := cfg.Server.Port if port == 0 { - port = defaultServerPort + port = ports.Backend } go func() { log.Info("WebSocket server listening", zap.Int("port", port)) @@ -506,7 +503,7 @@ func buildHTTPServer( port := cfg.Server.Port if port == 0 { - port = defaultServerPort + port = ports.Backend } return &http.Server{ Addr: fmt.Sprintf(":%d", port), diff --git a/apps/backend/cmd/preview/build.go b/apps/backend/cmd/preview/build.go new file mode 100644 index 0000000000..6190e7fa06 --- /dev/null +++ b/apps/backend/cmd/preview/build.go @@ -0,0 +1,271 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" +) + +// appsDir is the apps/ workspace root relative to the working directory (apps/backend/). +const appsDir = ".." + +// goDockerImage is used to cross-compile CGO binaries on non-linux/amd64 hosts. +const goDockerImage = "golang:1.26-bookworm" + +// buildLinuxBinaries compiles kandev, agentctl, and mock-agent for linux/amd64. +// Run this from apps/backend/. agentctl and mock-agent use CGO_ENABLED=0 and +// always build natively. kandev requires CGO (SQLite) and is always built inside +// a Docker container to target a known glibc version (Debian Bookworm = 2.36). +func buildLinuxBinaries(ctx context.Context, outDir string) error { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + + // agentctl and mock-agent don't need CGO — build natively with cross-env. + for _, b := range []struct{ name, pkg string }{ + {"agentctl", "./cmd/agentctl"}, + {"mock-agent", "./cmd/mock-agent"}, + } { + out := filepath.Join(outDir, b.name) + fmt.Fprintf(os.Stderr, " go build %s -> %s\n", b.pkg, out) + cmd := exec.CommandContext(ctx, "go", "build", "-ldflags", "-s -w", "-o", out, b.pkg) + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("build %s: %w", b.name, err) + } + } + + // kandev requires CGO for SQLite. Always build inside Docker so the resulting + // binary targets a known glibc version (Debian Bookworm = 2.36) regardless of + // the host OS. Sprites VMs run a compatible glibc; building natively on the + // CI runner (Ubuntu 24.04, glibc 2.39) would produce a binary that requires + // symbols unavailable in the Sprites environment. + kandevOut := filepath.Join(outDir, "kandev") + fmt.Fprintf(os.Stderr, " go build ./cmd/kandev (docker cross-compile) -> %s\n", kandevOut) + return buildKandevDocker(ctx, kandevOut) +} + +// buildKandevDocker builds kandev inside a linux/amd64 Docker container. +// apps/backend is mounted at /work; the output is written to /work/bin/kandev +// then copied to the host out path. +func buildKandevDocker(ctx context.Context, out string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + // Ensure the host-side bin/ directory exists before Docker tries to write + // into it. `go build -o` does not create missing parent directories. + if err := os.MkdirAll(filepath.Join(wd, "bin"), 0o755); err != nil { + return fmt.Errorf("mkdir bin: %w", err) + } + // Output inside the container (relative to /work mount). + containerOut := "/work/bin/kandev-preview-build" + goCache := filepath.Join(os.Getenv("HOME"), ".cache", "go-build-linux") + goModCache := filepath.Join(os.Getenv("HOME"), "go", "pkg", "mod") + + cmd := exec.CommandContext(ctx, "docker", "run", "--rm", + "--platform", "linux/amd64", + "-v", wd+":/work", + "-v", goCache+":/root/.cache/go-build", + "-v", goModCache+":/go/pkg/mod", + "-w", "/work", + goDockerImage, + "go", "build", "-ldflags", "-s -w", + "-o", containerOut, + "./cmd/kandev", + ) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker build kandev: %w", err) + } + + // Copy from temp path (inside /work mount) to the desired output path. + // os.Rename fails with EXDEV when hostTmp and out are on different filesystems + // (e.g. the repo is on a regular disk and out is under os.TempDir() on tmpfs). + hostTmp := filepath.Join(wd, "bin", "kandev-preview-build") + defer func() { _ = os.Remove(hostTmp) }() + return copyFile(hostTmp, out, 0o755) +} + +func copyFile(src, dst string, mode fs.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer func() { _ = out.Close() }() + + if _, err := io.Copy(out, in); err != nil { + return fmt.Errorf("copy %s → %s: %w", src, dst, err) + } + return nil +} + +// buildWeb runs the Next.js production build. When skipInstall is true the +// pnpm install step is skipped (CI already runs it before invoking the CLI). +func buildWeb(ctx context.Context, skipInstall bool) error { + steps := []struct { + desc string + args []string + }{ + {"pnpm install", []string{"pnpm", "install", "--frozen-lockfile"}}, + {"pnpm build web", []string{"pnpm", "--filter", "@kandev/web", "build"}}, + } + for _, step := range steps { + if skipInstall && step.desc == "pnpm install" { + continue + } + fmt.Fprintf(os.Stderr, " %s\n", step.desc) + cmd := exec.CommandContext(ctx, step.args[0], step.args[1:]...) + cmd.Dir = appsDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %w", step.desc, err) + } + } + return nil +} + +// packageBundle creates a tar.gz matching the Docker container layout: +// +// app/apps/backend/bin/{kandev,agentctl,mock-agent} +// app/apps/web/.next/standalone/ (Next.js server + node_modules) +// app/apps/web/.next/standalone/web/.next/static/ (static assets inside standalone) +// app/apps/web/.next/standalone/web/public/ (public assets inside standalone) +func packageBundle(binDir, tarPath string) error { + f, err := os.Create(tarPath) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + + // Add binaries. + binEntries, err := os.ReadDir(binDir) + if err != nil { + return fmt.Errorf("read bin dir: %w", err) + } + for _, e := range binEntries { + src := filepath.Join(binDir, e.Name()) + dst := filepath.Join("app", "apps", "backend", "bin", e.Name()) + if err := addFileToTar(tw, src, dst, 0o755); err != nil { + return err + } + } + + // Add Next.js standalone output. + standaloneDir := filepath.Join(appsDir, "web", ".next", "standalone") + if err := addDirToTar(tw, standaloneDir, filepath.Join("app", "apps", "web", ".next", "standalone")); err != nil { + return fmt.Errorf("add standalone: %w", err) + } + + // Add static assets into the standalone web dir (mirrors the Dockerfile COPY). + staticDir := filepath.Join(appsDir, "web", ".next", "static") + staticDst := filepath.Join("app", "apps", "web", ".next", "standalone", "web", ".next", "static") + if err := addDirToTar(tw, staticDir, staticDst); err != nil { + return fmt.Errorf("add static: %w", err) + } + + // Add public directory into the standalone web dir. + publicDir := filepath.Join(appsDir, "web", "public") + publicDst := filepath.Join("app", "apps", "web", ".next", "standalone", "web", "public") + if err := addDirToTar(tw, publicDir, publicDst); err != nil { + return fmt.Errorf("add public: %w", err) + } + + // Close in order: tar → gzip → file (flush compressed data). + if err := tw.Close(); err != nil { + return fmt.Errorf("close tar: %w", err) + } + return gz.Close() +} + +func addFileToTar(tw *tar.Writer, src, dst string, mode fs.FileMode) error { + data, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("read %s: %w", src, err) + } + hdr := &tar.Header{ + Name: dst, + Mode: int64(mode), + Size: int64(len(data)), + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + _, err = tw.Write(data) + return err +} + +func addDirToTar(tw *tar.Writer, srcDir, dstPrefix string) error { + return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + dst := filepath.Join(dstPrefix, rel) + + info, err := d.Info() + if err != nil { + return err + } + + // Preserve symlinks as-is (Next.js standalone relies on them for node_modules). + if info.Mode()&fs.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err != nil { + return err + } + hdr := &tar.Header{ + Typeflag: tar.TypeSymlink, + Name: dst, + Linkname: target, + } + return tw.WriteHeader(hdr) + } + + if d.IsDir() { + return tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: dst + "/", + Mode: 0o755, + }) + } + + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + hdr.Name = dst + + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + if err := tw.WriteHeader(hdr); err != nil { + return err + } + _, err = io.Copy(tw, f) + return err + }) +} diff --git a/apps/backend/cmd/preview/cleanup.go b/apps/backend/cmd/preview/cleanup.go new file mode 100644 index 0000000000..42f5d49207 --- /dev/null +++ b/apps/backend/cmd/preview/cleanup.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" +) + +func runCleanup(ctx context.Context, args []string) int { + fs := flag.NewFlagSet("cleanup", flag.ContinueOnError) + pr := fs.Int("pr", 0, "PR number (required)") + repo := fs.String("repo", envOr("GITHUB_REPOSITORY", ""), "owner/repo") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "preview cleanup: %v\n", err) + return 2 + } + if *pr == 0 { + fmt.Fprintln(os.Stderr, "preview cleanup: --pr is required") + return 2 + } + if *repo == "" { + fmt.Fprintln(os.Stderr, "preview cleanup: --repo or GITHUB_REPOSITORY is required") + return 2 + } + + spritesToken := os.Getenv("SPRITES_API_TOKEN") + if spritesToken == "" { + fmt.Fprintln(os.Stderr, "preview cleanup: SPRITES_API_TOKEN is required") + return 2 + } + ghToken := os.Getenv("GH_TOKEN") + if ghToken == "" { + fmt.Fprintln(os.Stderr, "preview cleanup: GH_TOKEN is required") + return 2 + } + + spriteName := fmt.Sprintf("kandev-pr-%d", *pr) + + client := newSpriteClient(spritesToken) + defer func() { _ = client.Close() }() + + fmt.Fprintf(os.Stderr, "destroying sprite %s...\n", spriteName) + _, destroyErr := destroySprite(ctx, client, spriteName) + if destroyErr != nil { + fmt.Fprintf(os.Stderr, "preview cleanup: destroy sprite: %v\n", destroyErr) + // Continue to update the PR description even if destroy failed. + } + + if err := removeDescriptionSection(ctx, ghToken, *repo, *pr); err != nil { + fmt.Fprintf(os.Stderr, "preview cleanup: remove PR description section: %v\n", err) + return 1 + } + + if destroyErr != nil { + return 1 + } + return 0 +} diff --git a/apps/backend/cmd/preview/deploy.go b/apps/backend/cmd/preview/deploy.go new file mode 100644 index 0000000000..90e86bbdf4 --- /dev/null +++ b/apps/backend/cmd/preview/deploy.go @@ -0,0 +1,157 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/kandev/kandev/internal/common/ports" + sprites "github.com/superfly/sprites-go" +) + +func runDeploy(ctx context.Context, args []string) int { + fs := flag.NewFlagSet("deploy", flag.ContinueOnError) + pr := fs.Int("pr", 0, "PR number (required)") + sha := fs.String("sha", "", "commit SHA to display in the comment") + repo := fs.String("repo", envOr("GITHUB_REPOSITORY", ""), "owner/repo") + port := fs.Int("port", ports.Backend, "kandev backend port exposed by the sprite") + skipWebInstall := fs.Bool("skip-web-install", false, "skip pnpm install (CI already ran it)") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "preview deploy: %v\n", err) + return 2 + } + if *pr == 0 { + fmt.Fprintln(os.Stderr, "preview deploy: --pr is required") + return 2 + } + if *repo == "" { + fmt.Fprintln(os.Stderr, "preview deploy: --repo or GITHUB_REPOSITORY is required") + return 2 + } + + spritesToken := os.Getenv("SPRITES_API_TOKEN") + if spritesToken == "" { + fmt.Fprintln(os.Stderr, "preview deploy: SPRITES_API_TOKEN is required") + return 2 + } + ghToken := os.Getenv("GH_TOKEN") + if ghToken == "" { + fmt.Fprintln(os.Stderr, "preview deploy: GH_TOKEN is required") + return 2 + } + + spriteName := fmt.Sprintf("kandev-pr-%d", *pr) + + tmpDir, err := os.MkdirTemp("", "kandev-preview-*") + if err != nil { + fmt.Fprintf(os.Stderr, "preview deploy: mktemp: %v\n", err) + return 1 + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + binDir := filepath.Join(tmpDir, "bin") + tarPath := filepath.Join(tmpDir, "kandev-preview.tar.gz") + + previewURL, err := deployArtifacts(ctx, binDir, tarPath, spritesToken, spriteName, *port, *skipWebInstall) + if err != nil { + fmt.Fprintf(os.Stderr, "preview deploy: %v\n", err) + return 1 + } + + fmt.Printf("preview URL: %s\n", previewURL) + + section := buildDeploySection(previewURL, *sha) + if err := upsertDescriptionSection(ctx, ghToken, *repo, *pr, section); err != nil { + fmt.Fprintf(os.Stderr, "preview deploy: update PR description: %v\n", err) + return 1 + } + + return 0 +} + +// deployArtifacts builds the bundle, deploys it to the sprite, and returns the public URL. +// Using a single client for the full flow avoids redundant auth round-trips. +func deployArtifacts(ctx context.Context, binDir, tarPath, spritesToken, spriteName string, port int, skipWebInstall bool) (string, error) { + fmt.Fprintln(os.Stderr, "building linux/amd64 binaries...") + if err := buildLinuxBinaries(ctx, binDir); err != nil { + return "", fmt.Errorf("build binaries: %w", err) + } + + fmt.Fprintln(os.Stderr, "building web frontend...") + if err := buildWeb(ctx, skipWebInstall); err != nil { + return "", fmt.Errorf("build web: %w", err) + } + + fmt.Fprintln(os.Stderr, "packaging bundle...") + if err := packageBundle(binDir, tarPath); err != nil { + return "", fmt.Errorf("package bundle: %w", err) + } + + client := newSpriteClient(spritesToken) + defer func() { _ = client.Close() }() + + fmt.Fprintf(os.Stderr, "getting or creating sprite %s...\n", spriteName) + sprite, err := getOrCreateSprite(ctx, client, spriteName) + if err != nil { + return "", fmt.Errorf("get/create sprite: %w", err) + } + + fmt.Fprintln(os.Stderr, "uploading bundle...") + if err := uploadBundle(ctx, sprite, tarPath); err != nil { + return "", fmt.Errorf("upload bundle: %w", err) + } + + fmt.Fprintln(os.Stderr, "extracting and configuring...") + if err := extractBundle(ctx, sprite, port); err != nil { + return "", fmt.Errorf("extract bundle: %w", err) + } + + // Enable the public URL before deploying the service. This sets the auth + // mode on the sprite (not the service), so it is safe to call before the + // service exists. Doing it first avoids a service restart mid-health-check. + previewURL, err := enablePublicURL(ctx, client, spriteName) + if err != nil { + return "", fmt.Errorf("enable public URL: %w", err) + } + + fmt.Fprintln(os.Stderr, "deploying kandev service...") + if err := deployService(ctx, sprite, port); err != nil { + return "", fmt.Errorf("deploy service: %w", err) + } + + // Health check via internal localhost to avoid Sprites routing lag during + // service transitions (the external URL may return 502 while the new + // service process is starting up). + fmt.Fprintln(os.Stderr, "waiting for kandev to be healthy...") + if err := waitForKandev(ctx, sprite, port); err != nil { + return "", fmt.Errorf("health check: %w", err) + } + + return previewURL, nil +} + +// enablePublicURL sets the sprite's URL to public mode and returns the URL. +// Accepts the already-open client from deployArtifacts to avoid a second auth handshake. +func enablePublicURL(ctx context.Context, client *sprites.Client, spriteName string) (string, error) { + updateCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout) + defer cancel() + + if err := client.UpdateURLSettings(updateCtx, spriteName, &sprites.URLSettings{Auth: "public"}); err != nil { + return "", fmt.Errorf("update URL settings: %w", err) + } + + getCtx, getCancel := context.WithTimeout(ctx, spriteStepTimeout) + defer getCancel() + + sprite, err := client.GetSprite(getCtx, spriteName) + if err != nil { + return "", fmt.Errorf("get sprite URL: %w", err) + } + if sprite.URL == "" { + return "", fmt.Errorf("sprite %s has no URL assigned yet", spriteName) + } + return sprite.URL, nil +} diff --git a/apps/backend/cmd/preview/github.go b/apps/backend/cmd/preview/github.go new file mode 100644 index 0000000000..2d556b43ff --- /dev/null +++ b/apps/backend/cmd/preview/github.go @@ -0,0 +1,187 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + sectionStart = "" + sectionEnd = "" + githubAPIBase = "https://api.github.com" +) + +// upsertDescriptionSection appends (or updates) the kandev preview section at +// the end of the PR description. Other bots may also append sections; we only +// touch the block delimited by our own markers. +func upsertDescriptionSection(ctx context.Context, token, repo string, pr int, section string) error { + body, err := getPRBody(ctx, token, repo, pr) + if err != nil { + return fmt.Errorf("get PR body: %w", err) + } + + newBody := replaceOrAppendSection(body, section) + return updatePRBody(ctx, token, repo, pr, newBody) +} + +// removeDescriptionSection removes the kandev preview section from the PR +// description on cleanup. If no section is present this is a no-op. +func removeDescriptionSection(ctx context.Context, token, repo string, pr int) error { + body, err := getPRBody(ctx, token, repo, pr) + if err != nil { + return fmt.Errorf("get PR body: %w", err) + } + + if !strings.Contains(body, sectionStart) { + return nil // nothing to remove + } + + newBody := stripSection(body) + return updatePRBody(ctx, token, repo, pr, newBody) +} + +// replaceOrAppendSection replaces an existing kandev section in body with +// section, or appends it if none exists. Preserves any trailing newline. +func replaceOrAppendSection(body, section string) string { + marked := sectionStart + "\n" + section + "\n" + sectionEnd + if strings.Contains(body, sectionStart) { + return replaceSection(body, marked) + } + sep := "\n\n" + if body == "" { + sep = "" + } + return body + sep + marked +} + +// replaceSection replaces everything between our markers (inclusive) with marked. +// If sectionEnd is missing (orphaned start marker), the orphan is stripped +// and marked is appended to avoid duplicating sectionStart. +func replaceSection(body, marked string) string { + start := strings.Index(body, sectionStart) + end := strings.Index(body, sectionEnd) + if start == -1 { + return body + "\n\n" + marked + } + if end == -1 || end < start { + // Orphaned start marker — strip from the start marker to end of string, + // then append the new section. + prefix := strings.TrimRight(body[:start], "\n") + if prefix == "" { + return marked + } + return prefix + "\n\n" + marked + } + end += len(sectionEnd) + return body[:start] + marked + body[end:] +} + +// stripSection removes the entire kandev section block (markers + content) +// including any leading blank line that we added before the section. +// Mirrors replaceSection's orphan handling: if sectionEnd is missing, the +// orphaned start marker (and everything after it) is stripped. +func stripSection(body string) string { + start := strings.Index(body, sectionStart) + if start == -1 { + return body + } + end := strings.Index(body, sectionEnd) + if end == -1 || end < start { + // Orphaned start marker — strip from marker to end of string. + return strings.TrimRight(body[:start], "\n") + } + end += len(sectionEnd) + + // Strip the blank line separator we added before the section. + prefix := body[:start] + prefix = strings.TrimRight(prefix, "\n") + + suffix := body[end:] + if prefix == "" { + return strings.TrimLeft(suffix, "\n") + } + return prefix + suffix +} + +func getPRBody(ctx context.Context, token, repo string, pr int) (string, error) { + url := fmt.Sprintf("%s/repos/%s/pulls/%d", githubAPIBase, repo, pr) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("get PR status %d: %s", resp.StatusCode, b) + } + + var result struct { + Body string `json:"body"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.Body, nil +} + +func updatePRBody(ctx context.Context, token, repo string, pr int, body string) error { + url := fmt.Sprintf("%s/repos/%s/pulls/%d", githubAPIBase, repo, pr) + return postJSON(ctx, token, http.MethodPatch, url, map[string]string{"body": body}) +} + +func postJSON(ctx context.Context, token, method, url string, payload any) error { + data, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%s %s: status %d: %s", method, url, resp.StatusCode, b) + } + return nil +} + +// buildDeploySection returns the markdown section body for a deploy update. +func buildDeploySection(previewURL, sha string) string { + shaDisplay := sha + if len(sha) > 7 { + shaDisplay = sha[:7] + } + return fmt.Sprintf(`### Preview Environment + +| | | +|---|---| +| **URL** | %s | +| **Commit** | `+"`%s`"+` | +| **Agent** | Mock agent | + +> Updates automatically on each push. Destroyed when the PR is closed.`, + previewURL, shaDisplay) +} diff --git a/apps/backend/cmd/preview/github_test.go b/apps/backend/cmd/preview/github_test.go new file mode 100644 index 0000000000..b69e8fac59 --- /dev/null +++ b/apps/backend/cmd/preview/github_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "strings" + "testing" +) + +func TestReplaceOrAppendSection_append(t *testing.T) { + body := "Existing PR description." + section := "### Preview\nURL: https://example.com" + got := replaceOrAppendSection(body, section) + + if !strings.Contains(got, sectionStart) { + t.Error("expected sectionStart marker") + } + if !strings.Contains(got, sectionEnd) { + t.Error("expected sectionEnd marker") + } + if !strings.HasPrefix(got, body) { + t.Errorf("expected original body to be preserved at start; got: %q", got) + } +} + +func TestReplaceOrAppendSection_replace(t *testing.T) { + old := "### Preview\nURL: https://old.example.com" + body := "Existing body.\n\n" + sectionStart + "\n" + old + "\n" + sectionEnd + + newSection := "### Preview\nURL: https://new.example.com" + got := replaceOrAppendSection(body, newSection) + + if strings.Contains(got, "old.example.com") { + t.Error("expected old section to be replaced") + } + if !strings.Contains(got, "new.example.com") { + t.Error("expected new section to be present") + } + // Only one start marker. + if strings.Count(got, sectionStart) != 1 { + t.Errorf("expected exactly one start marker, got %d", strings.Count(got, sectionStart)) + } +} + +func TestReplaceOrAppendSection_emptyBody(t *testing.T) { + section := "### Preview" + got := replaceOrAppendSection("", section) + if !strings.HasPrefix(got, sectionStart) { + t.Errorf("expected section at start of empty body; got: %q", got) + } +} + +func TestStripSection(t *testing.T) { + section := "### Preview\nURL: https://example.com" + body := "My PR description.\n\n" + sectionStart + "\n" + section + "\n" + sectionEnd + got := stripSection(body) + + if strings.Contains(got, sectionStart) || strings.Contains(got, sectionEnd) { + t.Error("expected section markers to be removed") + } + if !strings.Contains(got, "My PR description.") { + t.Error("expected original body to be preserved") + } + if strings.Contains(got, "https://example.com") { + t.Error("expected section content to be removed") + } +} + +func TestStripSection_noMarker(t *testing.T) { + body := "No preview section here." + got := stripSection(body) + if got != body { + t.Errorf("expected no-op; got %q", got) + } +} + +func TestStripSection_otherBotAppended(t *testing.T) { + // Simulates another bot having appended its own section after ours. + section := "### Preview" + otherBot := "\nOther bot content\n" + body := "PR body.\n\n" + sectionStart + "\n" + section + "\n" + sectionEnd + "\n\n" + otherBot + got := stripSection(body) + + if strings.Contains(got, sectionStart) { + t.Error("expected our section to be removed") + } + if !strings.Contains(got, "other-bot-start") { + t.Error("expected other bot section to be preserved") + } +} + +func TestReplaceSection_orphanStart(t *testing.T) { + // Orphaned start marker (no end) should not produce a duplicate start marker. + body := "PR body.\n\n" + sectionStart + "\ntruncated content with no end" + newSection := "### Preview\nURL: https://new.example.com" + got := replaceOrAppendSection(body, newSection) + + if strings.Count(got, sectionStart) != 1 { + t.Errorf("expected exactly one start marker, got %d: %q", strings.Count(got, sectionStart), got) + } + if !strings.Contains(got, "new.example.com") { + t.Error("expected new section content to be present") + } + if strings.Contains(got, "truncated content") { + t.Error("expected orphaned content to be removed") + } +} + +func TestBuildDeploySection(t *testing.T) { + tests := []struct { + sha string + wantSha string + }{ + {"abc1234", "abc1234"}, // exactly 7: no truncation + {"abc1234deadbeef", "abc1234"}, // > 7: truncated to 7 + {"abc", "abc"}, // < 7: kept as-is + } + for _, tc := range tests { + got := buildDeploySection("https://example.com", tc.sha) + if !strings.Contains(got, "`"+tc.wantSha+"`") { + t.Errorf("sha=%q: expected %q in output, got: %s", tc.sha, tc.wantSha, got) + } + } +} diff --git a/apps/backend/cmd/preview/main.go b/apps/backend/cmd/preview/main.go new file mode 100644 index 0000000000..03f532fac9 --- /dev/null +++ b/apps/backend/cmd/preview/main.go @@ -0,0 +1,72 @@ +// Command preview deploys and manages PR preview environments on Sprites.dev. +// +// Usage: +// +// preview deploy --pr N --sha S [--repo owner/repo] [--port 38429] +// preview cleanup --pr N [--repo owner/repo] +// +// Required environment variables: +// +// SPRITES_API_TOKEN Sprites.dev API token +// GH_TOKEN GitHub token for posting PR comments +// GITHUB_REPOSITORY Repository in owner/repo format +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" +) + +func main() { os.Exit(run()) } + +func run() int { + if len(os.Args) < 2 { + usage() + return 2 + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + switch os.Args[1] { + case "deploy": + return runDeploy(ctx, os.Args[2:]) + case "cleanup": + return runCleanup(ctx, os.Args[2:]) + case "-h", "--help", "help": + usage() + return 0 + default: + fmt.Fprintf(os.Stderr, "preview: unknown subcommand %q\n\n", os.Args[1]) + usage() + return 2 + } +} + +func usage() { + fmt.Fprintln(os.Stderr, `preview — deploy and manage PR preview environments on Sprites.dev + +Usage: + preview deploy --pr N --sha S [--repo owner/repo] [--port 38429] + preview cleanup --pr N [--repo owner/repo] + +Environment variables: + SPRITES_API_TOKEN Sprites.dev API token (required) + GH_TOKEN GitHub token for posting PR comments (required) + GITHUB_REPOSITORY GitHub repository in owner/repo format (required) + +Local usage: + cd apps/backend + SPRITES_API_TOKEN=xxx GH_TOKEN=xxx GITHUB_REPOSITORY=owner/repo \ + go run ./cmd/preview deploy --pr 123 --sha abc1234`) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/apps/backend/cmd/preview/sprite_ops.go b/apps/backend/cmd/preview/sprite_ops.go new file mode 100644 index 0000000000..a87925a788 --- /dev/null +++ b/apps/backend/cmd/preview/sprite_ops.go @@ -0,0 +1,314 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/kandev/kandev/internal/common/ports" + sprites "github.com/superfly/sprites-go" +) + +const ( + spriteUploadTimeout = 10 * time.Minute // bundles can be large + spriteStepTimeout = 2 * time.Minute + spriteUploadRetries = 3 + spriteBackoffInit = 700 * time.Millisecond +) + +func newSpriteClient(token string) *sprites.Client { + return sprites.New(token) +} + +// getOrCreateSprite returns an existing sprite or creates a new one. +// Cold/sleeping sprites wake automatically when commands are issued. +func getOrCreateSprite(ctx context.Context, client *sprites.Client, name string) (*sprites.Sprite, error) { + stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout) + defer cancel() + + sprite, err := client.GetSprite(stepCtx, name) + if err == nil { + return sprite, nil + } + // The SDK returns a plain fmt.Errorf("sprite not found: %s") for 404s — + // no typed error is available, so we check the message string. + if !strings.Contains(err.Error(), "not found") { + return nil, fmt.Errorf("get sprite: %w", err) + } + + createCtx, createCancel := context.WithTimeout(ctx, spriteStepTimeout) + defer createCancel() + + sprite, err = client.CreateSprite(createCtx, name, nil) + if err != nil { + return nil, fmt.Errorf("create sprite: %w", err) + } + return sprite, nil +} + +// uploadBundle uploads the bundle tarball to the sprite via the Filesystem API. +// Retries up to spriteUploadRetries times on transient errors with context-aware backoff. +func uploadBundle(ctx context.Context, sprite *sprites.Sprite, tarPath string) error { + data, err := os.ReadFile(tarPath) + if err != nil { + return fmt.Errorf("read bundle: %w", err) + } + + uploadCtx, cancel := context.WithTimeout(ctx, spriteUploadTimeout) + defer cancel() + + backoff := spriteBackoffInit + var lastErr error + for attempt := 1; attempt <= spriteUploadRetries; attempt++ { + err := sprite.Filesystem().WriteFileContext(uploadCtx, "/tmp/kandev-preview.tar.gz", data, 0o644) + if err == nil { + return nil + } + lastErr = err + if attempt == spriteUploadRetries || uploadCtx.Err() != nil { + break + } + fmt.Fprintf(os.Stderr, " upload attempt %d failed (%v), retrying in %v...\n", attempt, err, backoff) + select { + case <-uploadCtx.Done(): + return uploadCtx.Err() + case <-time.After(backoff): + } + backoff *= 2 + } + return fmt.Errorf("upload bundle after %d attempts: %w", spriteUploadRetries, lastErr) +} + +// extractBundle extracts the bundle tarball and writes the startup script. +func extractBundle(ctx context.Context, sprite *sprites.Sprite, port int) error { + script := buildExtractScript(port) + stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout) + defer cancel() + + out, err := sprite.CommandContext(stepCtx, "bash", "-c", script).CombinedOutput() + if len(out) > 0 { + fmt.Fprintf(os.Stderr, "%s\n", string(out)) + } + if err != nil { + return fmt.Errorf("extract bundle: %w", err) + } + return nil +} + +func buildExtractScript(backendPort int) string { + return fmt.Sprintf(`set -e +tar -xzf /tmp/kandev-preview.tar.gz -C / +chmod +x /app/apps/backend/bin/kandev \ + /app/apps/backend/bin/agentctl \ + /app/apps/backend/bin/mock-agent +ln -sf /app/apps/backend/bin/agentctl /usr/local/bin/agentctl +ln -sf /app/apps/backend/bin/mock-agent /usr/local/bin/mock-agent +# Reset data directory on each deploy so the DB starts fresh (preview env only). +rm -rf /data +mkdir -p /data /var/log +echo "=== kandev binary info ===" +ldd /app/apps/backend/bin/kandev 2>&1 || true +echo "===========================" +cat > /app/start-kandev.sh << 'STARTSCRIPT' +#!/bin/bash +set -e +mkdir -p /data + +# Kill any agentctl orphans from previous runs. +pkill -f agentctl || true +# Kill any stale web (node) process — nohup'd node survives StopService since +# it detaches from the process group, and would hold the web port on redeploy. +pkill -f '/app/apps/web/.next/standalone/web/server.js' || true +sleep 1 + +# Start Next.js web server in background. +PORT=%d HOSTNAME=0.0.0.0 NODE_ENV=production \ + nohup node /app/apps/web/.next/standalone/web/server.js \ + > /var/log/kandev-web.log 2>&1 & + +# Start Go backend (main process — Sprites HTTPPort proxies here). +export KANDEV_HOME_DIR=/data +export KANDEV_DOCKER_ENABLED=false +export KANDEV_LOG_LEVEL=info +export KANDEV_SERVER_PORT=%d +export KANDEV_WEB_INTERNAL_URL=http://localhost:%d +# Preview mode: only register the mock agent, suppress real agent discovery. +export KANDEV_MOCK_AGENT=only +/app/apps/backend/bin/kandev > /var/log/kandev.log 2>&1 +STARTSCRIPT +chmod +x /app/start-kandev.sh`, ports.Web, backendPort, ports.Web) +} + +// deployService stops any running kandev service, registers (or updates) its +// config, then explicitly starts it. Three-step stop → create → start ensures +// the process is restarted on re-deploy and started on first deploy: +// - StopService terminates any running instance (no-op if none exists). +// - CreateService is idempotent: creates or updates the service config. +// - StartService explicitly starts the process regardless of prior state. +func deployService(ctx context.Context, sprite *sprites.Sprite, port int) error { + // Stop any running instance so StartService always spawns a fresh process. + // Errors here are best-effort — the service may not exist yet on first deploy. + stopCtx, stopCancel := context.WithTimeout(ctx, spriteStepTimeout) + defer stopCancel() + if stream, err := sprite.StopService(stopCtx, "kandev"); err == nil { + drainStream(stream) + } + + createCtx, createCancel := context.WithTimeout(ctx, spriteStepTimeout) + defer createCancel() + + createStream, err := sprite.CreateService(createCtx, "kandev", &sprites.ServiceRequest{ + Cmd: "/app/start-kandev.sh", + HTTPPort: &port, + }) + if err != nil { + return fmt.Errorf("create service: %w", err) + } + // Drain create stream (informational only). + for { + ev, err := createStream.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + _ = createStream.Close() + return fmt.Errorf("create service stream: %w", err) + } + if ev.Type == "error" { + _ = createStream.Close() + return fmt.Errorf("create service error: %s", ev.Data) + } + } + _ = createStream.Close() + + // Explicitly start the fresh service. + startCtx, startCancel := context.WithTimeout(ctx, spriteStepTimeout) + defer startCancel() + + startStream, err := sprite.StartService(startCtx, "kandev") + if err != nil { + return fmt.Errorf("start service: %w", err) + } + defer func() { _ = startStream.Close() }() + + return waitForServiceStarted(startStream) +} + +// drainStream reads all events from a ServiceStream and closes it. +// Used for informational streams where we don't need to act on the content. +func drainStream(stream *sprites.ServiceStream) { + defer func() { _ = stream.Close() }() + for { + _, err := stream.Next() + if err != nil { + return + } + } +} + +func waitForServiceStarted(stream *sprites.ServiceStream) error { + // CreateService returns HTTP 200 when the service is registered; the stream + // carries optional progress events. EOF means the server finished streaming — + // treat it as success unless we saw an explicit failure event first. + // On existing sprites the service can start fast enough that no events arrive. + for { + event, err := stream.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("service stream: %w", err) + } + fmt.Fprintf(os.Stderr, " [service] type=%s data=%q\n", event.Type, event.Data) + switch event.Type { + case "started", "complete": + return nil + case "error": + return fmt.Errorf("service error: %s", event.Data) + case "exit": + code := -1 + if event.ExitCode != nil { + code = *event.ExitCode + } + return fmt.Errorf("service exited (code %d) before 'started'", code) + } + } +} + +// waitForKandev polls the kandev /health endpoint inside the sprite via +// CommandContext until kandev responds or the deadline is exceeded. +// Using the internal address (localhost) avoids Sprites routing state that +// may lag during a service restart triggered by enablePublicURL. +func waitForKandev(ctx context.Context, sprite *sprites.Sprite, port int) error { + const ( + timeout = 90 * time.Second + retryWait = 3 * time.Second + ) + healthURL := fmt.Sprintf("http://localhost:%d/health", port) + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + out, err := sprite.CommandContext(checkCtx, "curl", "-sf", healthURL).Output() + cancel() + + if err == nil && len(out) > 0 { + fmt.Fprintf(os.Stderr, " kandev is healthy\n") + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryWait): + } + } + + // Health check timed out — fetch logs to help diagnose. + diag := fetchSpriteLogs(ctx, sprite) + return fmt.Errorf("kandev did not become healthy within %v\n%s", timeout, diag) +} + +// fetchSpriteLogs reads log files from the sprite for failure diagnostics. +func fetchSpriteLogs(ctx context.Context, sprite *sprites.Sprite) string { + logCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + script := `echo "=== /var/log/kandev.log ==="; tail -50 /var/log/kandev.log 2>/dev/null || echo "(empty)"; echo "=== /var/log/kandev-web.log ==="; tail -20 /var/log/kandev-web.log 2>/dev/null || echo "(empty)"` + out, err := sprite.CommandContext(logCtx, "bash", "-c", script).CombinedOutput() + if err != nil { + return fmt.Sprintf("[log fetch error: %v]\n%s", err, string(out)) + } + return string(out) +} + +// destroySprite destroys the named sprite and returns its creation time for +// runtime calculation. Returns zero time if the sprite was not found. +func destroySprite(ctx context.Context, client *sprites.Client, name string) (time.Time, error) { + getCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout) + defer cancel() + + sprite, err := client.GetSprite(getCtx, name) + if err != nil { + // The SDK returns a plain fmt.Errorf("sprite not found: %s") for 404s. + if strings.Contains(err.Error(), "not found") { + fmt.Fprintf(os.Stderr, "sprite %s not found, skipping destroy\n", name) + return time.Time{}, nil + } + return time.Time{}, fmt.Errorf("get sprite: %w", err) + } + createdAt := sprite.CreatedAt + + destroyCtx, destroyCancel := context.WithTimeout(ctx, spriteStepTimeout) + defer destroyCancel() + + if err := sprite.Delete(destroyCtx); err != nil { + return createdAt, fmt.Errorf("delete sprite: %w", err) + } + fmt.Fprintf(os.Stderr, "sprite %s destroyed\n", name) + return createdAt, nil +} diff --git a/apps/backend/cmd/preview/sprite_ops_test.go b/apps/backend/cmd/preview/sprite_ops_test.go new file mode 100644 index 0000000000..3b73339ab6 --- /dev/null +++ b/apps/backend/cmd/preview/sprite_ops_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "strings" + "testing" +) + +func TestBuildExtractScript(t *testing.T) { + script := buildExtractScript(12345) + + if !strings.Contains(script, "KANDEV_SERVER_PORT=12345") { + t.Errorf("expected KANDEV_SERVER_PORT=12345 in script, got:\n%s", script) + } + if !strings.Contains(script, "rm -rf /data") { + t.Errorf("expected rm -rf /data in script") + } + if !strings.Contains(script, "KANDEV_MOCK_AGENT=only") { + t.Errorf("expected KANDEV_MOCK_AGENT=only in script") + } +} diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index d9bd72f592..0b71f94b90 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -12,14 +12,14 @@ import ( settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/common/ports" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" "go.opentelemetry.io/otel/trace" ) -// Default agentctl port. Clustered near the web port (37429) to avoid -// collisions with commonly used ports (8080, 9090, 9999, etc.). -const AgentCtlPort = 39429 +// AgentCtlPort is the default agentctl control port. +const AgentCtlPort = ports.AgentCtl // AgentExecution represents a running agent execution type AgentExecution struct { diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index da4ed856e4..0ee25e52e2 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -270,7 +270,9 @@ func (c *Controller) updateExistingProfiles(ctx context.Context, profiles []*mod func (c *Controller) syncAgentFromDiscovery(ctx context.Context, result discovery.Availability) error { agentConfig, ok := c.agentRegistry.Get(result.Name) if !ok { - return fmt.Errorf("unknown agent: %s", result.Name) + // Agent detected on filesystem but not in registry (e.g. KANDEV_MOCK_AGENT=only + // suppresses real agents). Skip silently rather than aborting the entire sync. + return nil } displayName, err := c.resolveDisplayName(agentConfig, result.Name) if err != nil { diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index 7223fef169..e496b90f54 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/modelfetcher" @@ -499,3 +500,21 @@ func TestController_GetAgentLogo_NoLogoData(t *testing.T) { t.Errorf("GetAgentLogo() error = %v, want ErrLogoNotAvailable", err) } } + +// TestSyncAgentFromDiscovery_UnknownAgentSkipped verifies that +// syncAgentFromDiscovery returns nil (not an error) when the discovered agent +// is not in the registry. This is the expected behaviour when +// KANDEV_MOCK_AGENT=only suppresses all real agents: the filesystem scanner may +// still detect installed CLIs, but they should be silently skipped rather than +// aborting the entire profile-sync loop. +func TestSyncAgentFromDiscovery_UnknownAgentSkipped(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{}) // empty registry + + result := discovery.Availability{ + Name: "some-unregistered-agent", + Available: true, + } + if err := ctrl.syncAgentFromDiscovery(context.Background(), result); err != nil { + t.Errorf("expected nil error for unknown agent, got: %v", err) + } +} diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index a192ac59dc..cc95985589 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/kandev/kandev/internal/common/ports" "github.com/spf13/viper" ) @@ -235,7 +236,7 @@ func detectDefaultLogFormat() string { func setDefaults(v *viper.Viper) { // Server defaults v.SetDefault("server.host", "0.0.0.0") - v.SetDefault("server.port", 38429) + v.SetDefault("server.port", ports.Backend) v.SetDefault("server.readTimeout", 30) v.SetDefault("server.writeTimeout", 30) v.SetDefault("server.webInternalUrl", "") @@ -274,9 +275,9 @@ func setDefaults(v *viper.Viper) { // Agent defaults (runtime selection is now per-task based on executor type) v.SetDefault("agent.standaloneHost", "localhost") - v.SetDefault("agent.standalonePort", 39429) + v.SetDefault("agent.standalonePort", ports.AgentCtl) v.SetDefault("agent.mcpServerEnabled", false) // MCP is now embedded in agentctl - v.SetDefault("agent.mcpServerPort", 40429) + v.SetDefault("agent.mcpServerPort", ports.MCP) v.SetDefault("agent.mcpServerUrl", "") // Auth defaults diff --git a/apps/backend/internal/common/ports/ports.go b/apps/backend/internal/common/ports/ports.go new file mode 100644 index 0000000000..c2ccf1509c --- /dev/null +++ b/apps/backend/internal/common/ports/ports.go @@ -0,0 +1,21 @@ +// Package ports defines the default service ports used by kandev. +// These values are the single source of truth in Go; the TypeScript CLI +// mirrors them in apps/cli/src/constants.ts. +// +// All four ports are clustered around 37429–40429 to avoid collisions with +// commonly used ports (8080, 9090, 9999, etc.) while keeping them memorable. +package ports + +const ( + // Backend is the default HTTP port for the kandev Go backend. + Backend = 38429 + + // Web is the default HTTP port for the Next.js web server. + Web = 37429 + + // AgentCtl is the default control port for the agentctl sidecar. + AgentCtl = 39429 + + // MCP is the default port for the standalone MCP server. + MCP = 40429 +) diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 1e1dee7178..63e81e203b 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -2,7 +2,13 @@ package github import ( "context" + "encoding/json" + "errors" "fmt" + "io" + "net/http" + "net/url" + "sort" "strconv" "strings" "sync" @@ -22,6 +28,13 @@ const ( AuthMethodPAT = "pat" ) +// defaultBranchMain and defaultBranchMaster are the conventional default branch +// names sorted to the top of branch pickers. +const ( + defaultBranchMain = "main" + defaultBranchMaster = "master" +) + // TaskDeleter deletes tasks by ID. Used for cleaning up merged PR tasks. type TaskDeleter interface { DeleteTask(ctx context.Context, taskID string) error @@ -1153,11 +1166,126 @@ func (s *Service) SearchOrgRepos(ctx context.Context, org, query string, limit i } // ListRepoBranches lists branches for a repository. +// When no authenticated client is configured, it falls back to the unauthenticated +// GitHub API so that public repositories remain accessible without a token. +// The result is sorted so that "main" appears first, "master" second, then all +// remaining branches alphabetically — matching the default branch convention and +// making the most common choices immediately visible in pickers. func (s *Service) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) { - if s.client == nil { - return nil, fmt.Errorf("github client not available") + var ( + branches []RepoBranch + err error + ) + if s.client != nil { + branches, err = s.client.ListRepoBranches(ctx, owner, repo) + if err != nil && !errors.Is(err, ErrNoClient) { + return nil, err + } } - return s.client.ListRepoBranches(ctx, owner, repo) + if branches == nil { + branches, err = listRepoBranchesAnonymous(ctx, owner, repo) + if err != nil { + return nil, err + } + } + sortBranchesMainFirst(branches) + return branches, nil +} + +// sortBranchesMainFirst sorts branches in-place: "main" first, "master" second, +// then all remaining branches alphabetically. +func sortBranchesMainFirst(branches []RepoBranch) { + priority := func(name string) int { + switch name { + case defaultBranchMain: + return 0 + case defaultBranchMaster: + return 1 + default: + return 2 + } + } + sort.SliceStable(branches, func(i, j int) bool { + pi, pj := priority(branches[i].Name), priority(branches[j].Name) + if pi != pj { + return pi < pj + } + return branches[i].Name < branches[j].Name + }) +} + +// anonymousAPIBase is the GitHub API base URL used by listRepoBranchesAnonymous. +// Overridden in tests to point at a local httptest server. +var anonymousAPIBase = githubAPIBase + +// anonymousHTTPClient is used by listRepoBranchesAnonymous. The 30 s timeout +// prevents a slow or unresponsive GitHub API from tying up server goroutines +// indefinitely across multi-page pagination. +var anonymousHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// listRepoBranchesAnonymous calls the GitHub REST API without authentication to +// list branches for public repositories, following pagination via Link headers. +// Returns ErrNoClient on network errors and a GitHubAPIError for non-2xx +// responses (404, 403, etc.) so the controller maps them to correct HTTP codes. +func listRepoBranchesAnonymous(ctx context.Context, owner, repo string) ([]RepoBranch, error) { + next := fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100", + anonymousAPIBase, url.PathEscape(owner), url.PathEscape(repo)) + var branches []RepoBranch + for next != "" { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil) + if err != nil { + return nil, ErrNoClient + } + req.Header.Set("Accept", githubAccept) + req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) + + resp, err := anonymousHTTPClient.Do(req) + if err != nil { + return nil, ErrNoClient + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return nil, &GitHubAPIError{ + StatusCode: resp.StatusCode, + Endpoint: next, + Body: string(body), + } + } + + var page []struct { + Name string `json:"name"` + } + err = json.NewDecoder(resp.Body).Decode(&page) + _ = resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("decode branches response: %w", err) + } + for _, b := range page { + branches = append(branches, RepoBranch{Name: b.Name}) + } + next = parseLinkNext(resp.Header.Get("Link")) + } + return branches, nil +} + +// parseLinkNext extracts the URL for rel="next" from a GitHub Link header. +// Returns "" if no next page is present. +func parseLinkNext(link string) string { + // Format: ; rel="next", ; rel="last" + for part := range strings.SplitSeq(link, ",") { + part = strings.TrimSpace(part) + if !strings.Contains(part, `rel="next"`) { + continue + } + if start := strings.Index(part, "<"); start != -1 { + if end := strings.Index(part, ">"); end != -1 && end > start { + return part[start+1 : end] + } + } + } + return "" } // SearchUserPRs searches for PRs using a filter or custom query. Unless the diff --git a/apps/backend/internal/github/service_test.go b/apps/backend/internal/github/service_test.go index bed5916df9..4cc69bf4e3 100644 --- a/apps/backend/internal/github/service_test.go +++ b/apps/backend/internal/github/service_test.go @@ -3,6 +3,11 @@ package github import ( "context" "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" "strings" "sync" "testing" @@ -15,6 +20,159 @@ import ( "github.com/kandev/kandev/internal/events/bus" ) +func TestParseLinkNext(t *testing.T) { + tests := []struct { + link string + want string + }{ + { + link: `; rel="next", ; rel="last"`, + want: "https://api.github.com/repos/o/r/branches?page=2", + }, + { + link: `; rel="last"`, + want: "", + }, + {link: "", want: ""}, + } + for _, tc := range tests { + got := parseLinkNext(tc.link) + if got != tc.want { + t.Errorf("parseLinkNext(%q) = %q, want %q", tc.link, got, tc.want) + } + } +} + +func TestListRepoBranches_Pagination(t *testing.T) { + page := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page++ + if page == 1 { + w.Header().Set("Link", fmt.Sprintf(`<%s/repos/owner/repo/branches?page=2>; rel="next"`, "http://"+r.Host)) + _ = json.NewEncoder(w).Encode([]map[string]any{{"name": "feature-a"}}) + } else { + _ = json.NewEncoder(w).Encode([]map[string]any{{"name": "main"}}) + } + })) + defer srv.Close() + + orig := anonymousAPIBase + anonymousAPIBase = srv.URL + defer func() { anonymousAPIBase = orig }() + + svc := &Service{client: &NoopClient{}} + branches, err := svc.ListRepoBranches(context.Background(), "owner", "repo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(branches) != 2 { + t.Fatalf("expected 2 branches across pages, got %d", len(branches)) + } + // main should appear first regardless of which page it was fetched from. + if branches[0].Name != "main" { + t.Errorf("expected main first, got %q; branches: %+v", branches[0].Name, branches) + } + if branches[1].Name != "feature-a" { + t.Errorf("expected feature-a second, got %q", branches[1].Name) + } +} + +func TestListRepoBranches_NoopClientFallback(t *testing.T) { + // A fake GitHub API that returns two branches for a public repo. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo/branches" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"name": "main"}, + {"name": "develop"}, + }) + })) + defer srv.Close() + + orig := anonymousAPIBase + anonymousAPIBase = srv.URL + defer func() { anonymousAPIBase = orig }() + + svc := &Service{client: &NoopClient{}} + branches, err := svc.ListRepoBranches(context.Background(), "owner", "repo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(branches) != 2 { + t.Fatalf("expected 2 branches, got %d", len(branches)) + } + if branches[0].Name != "main" || branches[1].Name != "develop" { + t.Errorf("unexpected branches: %+v", branches) + } +} + +func TestListRepoBranches_NoopClientFallback_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) + })) + defer srv.Close() + + orig := anonymousAPIBase + anonymousAPIBase = srv.URL + defer func() { anonymousAPIBase = orig }() + + svc := &Service{client: &NoopClient{}} + _, err := svc.ListRepoBranches(context.Background(), "owner", "private-repo") + if err == nil { + t.Fatal("expected error for private/missing repo") + } + var apiErr *GitHubAPIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected GitHubAPIError, got %T: %v", err, err) + } + if apiErr.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 status, got %d", apiErr.StatusCode) + } +} + +func TestSortBranchesMainFirst(t *testing.T) { + tests := []struct { + input []string + want []string + }{ + { + input: []string{"feature-b", "main", "feature-a", "master"}, + want: []string{"main", "master", "feature-a", "feature-b"}, + }, + { + input: []string{"develop", "master"}, + want: []string{"master", "develop"}, + }, + { + input: []string{"feature-a", "feature-b"}, + want: []string{"feature-a", "feature-b"}, + }, + { + input: []string{"main"}, + want: []string{"main"}, + }, + { + input: []string{}, + want: []string{}, + }, + } + for _, tc := range tests { + branches := make([]RepoBranch, len(tc.input)) + for i, n := range tc.input { + branches[i] = RepoBranch{Name: n} + } + sortBranchesMainFirst(branches) + for i, b := range branches { + if b.Name != tc.want[i] { + t.Errorf("input=%v: got[%d]=%q, want %q", tc.input, i, b.Name, tc.want[i]) + } + } + } +} + func TestGetPR_NilClient(t *testing.T) { svc := &Service{client: nil} _, err := svc.GetPR(context.Background(), "owner", "repo", 1) diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index 3864b030ed..313bb37334 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -207,40 +207,49 @@ func (s *Service) resolveAllowedLocalPath(repoPath string) (string, error) { if err != nil { return "", fmt.Errorf("invalid repository path: %w", err) } - safe, err := pathWithinRoots(abs, roots) + // Resolve symlinks before the allowlist check so that OS-level symlinks + // (e.g. /var -> /private/var on macOS) don't produce false negatives. + // normalizeRoots already resolves symlinks on the roots themselves, so both + // sides of the comparison are canonical after this step. + resolved, err := filepath.EvalSymlinks(abs) if err != nil { return "", err } - // Resolve symlinks then re-validate so a symlink within a root cannot - // escape outside the allowed roots. - resolved, err := filepath.EvalSymlinks(safe) + safe, err := pathWithinRoots(filepath.Clean(resolved), roots) if err != nil { return "", err } - resolvedSafe, err := pathWithinRoots(filepath.Clean(resolved), roots) - if err != nil { - return "", err - } - info, err := os.Stat(resolvedSafe) + info, err := os.Stat(safe) if err != nil { return "", err } if !info.IsDir() { return "", fmt.Errorf("repository path is not a directory") } - return resolvedSafe, nil + return safe, nil } // pathWithinRoots returns abs only when it sits inside one of the allowed // roots after resolving relative segments. The returned string is the // trusted, validated path; callers should never use the original input for // subsequent file operations. +// +// Each root is also passed through filepath.EvalSymlinks before comparison so +// that OS-level symlinks (e.g. /var -> /private/var on macOS) do not produce +// false negatives when abs was obtained via EvalSymlinks but the stored root +// was not. func pathWithinRoots(abs string, roots []string) (string, error) { for _, root := range roots { if root == "" { continue } - rel, err := filepath.Rel(root, abs) + // Resolve symlinks on the root so both sides of the comparison are + // canonical. Ignore errors — if the root itself doesn't exist we skip it. + canonRoot := root + if r, err := filepath.EvalSymlinks(root); err == nil { + canonRoot = r + } + rel, err := filepath.Rel(canonRoot, abs) if err != nil { continue }