diff --git a/.env.example b/.env.example index 93c6ccd..3f4dbb0 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,24 @@ -# Optional service-scoped public egress proxies. Git and the IM SDKs use the -# backend proxy; tinyauth uses its own proxy for GitHub OAuth token exchange. -# Other Compose services remain proxy-free. -MOONCODE_BACKEND_HTTP_PROXY=http://proxy.example:8123 -MOONCODE_BACKEND_HTTPS_PROXY=http://proxy.example:8123 -MOONCODE_TINYAUTH_HTTP_PROXY=http://proxy.example:8123 -MOONCODE_TINYAUTH_HTTPS_PROXY=http://proxy.example:8123 +# MoonCode application secrets. Generate distinct values with: +# openssl rand -base64 32 +MOONCODE_SECRETS_KEY= +MOONCODE_AUTH_CSRF_KEY= +# Direct peer CIDRs allowed to provide Tinyauth Remote-* headers. +MOONCODE_AUTH_TRUSTED_PROXY_CIDRS=172.30.0.2/32 -# Optional development overrides. Production deployments must generate and -# provide distinct 32-byte base64 values rather than using Compose defaults. -MOONCODE_SECRET_STORE_MASTER_KEY= -MOONCODE_CSRF_KEY= +# Maximum persistent size of one managed bare Git mirror (default: 10 GiB). +MOONCODE_REPOSITORY_MAX_MIRROR_BYTES=10737418240 +MOONCODE_REPOSITORY_MAX_PER_WORKSPACE=100 +MOONCODE_ANALYSIS_MAX_CONCURRENT_PER_WORKSPACE=10 +MOONCODE_METRICS_WORKER_ADDRESS=:9090 -# Required by the tinyauth GitHub OAuth provider. Create a GitHub OAuth App -# whose callback URL is: -# http://auth.mooncode.localhost:3100/api/oauth/callback/github +# A MoonCode-scoped token created by the self-hosted Hatchet instance. +HATCHET_CLIENT_TOKEN= + +# GitHub OAuth App used only by Tinyauth for MoonCode SaaS login. +# Callback: http://mooncode.localhost:3100/api/oauth/callback/github GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_SECRET_FILE=./secrets/github_oauth_client_secret -# invite_only is recommended for private testing; use public for SaaS signup. -MOONCODE_REGISTRATION_MODE=invite_only +# Optional service-scoped outbound proxies. +MOONCODE_BACKEND_HTTP_PROXY= +MOONCODE_TINYAUTH_HTTP_PROXY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 093ce52..6fb19aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,10 +49,10 @@ jobs: git diff --exit-code - name: Lint Go - uses: golangci/golangci-lint-action@v8 + uses: golangci/golangci-lint-action@v9.3.0 with: - version: v2.5.0 - args: ./cmd/... ./internal/... ./pkg/... ./migrations/... + version: v2.12.2 + args: ./api/... ./cmd/... ./internal/... ./pkg/... ./migrations/... - name: Test and build run: make test vet frontend-i18n frontend-lint frontend-build @@ -71,7 +71,25 @@ jobs: go-version-file: go.mod cache: true + - name: Start PostgreSQL + run: >- + docker run --rm -d --name mooncode-ci-postgres + -e POSTGRES_USER=mooncode + -e POSTGRES_PASSWORD=mooncode + -e POSTGRES_DB=mooncode + -p 5432:5432 postgres:17-bookworm + + - name: Wait for PostgreSQL + run: | + for attempt in $(seq 1 30); do + docker exec mooncode-ci-postgres pg_isready -U mooncode -d mooncode && exit 0 + sleep 1 + done + exit 1 + - name: Run integration tests + env: + MOONCODE_TEST_DATABASE_URL: postgres://mooncode:mooncode@127.0.0.1:5432/mooncode?sslmode=disable run: make integration-test secrets: @@ -90,3 +108,81 @@ jobs: -v "$PWD:/repo:ro" zricethezav/gitleaks:v8.28.0 detect --source=/repo --redact --verbose --exit-code=1 + + dependency-review: + name: Dependency review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Review dependency changes + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + + supply-chain: + name: Vulnerabilities, images, and SBOM + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci --prefix frontend + + - name: Scan Go dependencies + run: >- + go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 + ./api/... ./cmd/... ./internal/... ./pkg/... ./migrations/... + + - name: Audit frontend dependencies + run: npm --prefix frontend audit --audit-level=high + + - name: Build deployment images + run: | + docker build --file deploy/backend.Dockerfile --tag mooncode-backend:ci . + docker build --file deploy/frontend.Dockerfile --tag mooncode-frontend:ci . + + - name: Scan backend image + run: >- + docker run --rm + -v /var/run/docker.sock:/var/run/docker.sock + aquasec/trivy:0.66.0 + image --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL mooncode-backend:ci + + - name: Scan frontend image + run: >- + docker run --rm + -v /var/run/docker.sock:/var/run/docker.sock + aquasec/trivy:0.66.0 + image --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL mooncode-frontend:ci + + - name: Generate image SBOMs + run: | + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock anchore/syft:v1.33.0 mooncode-backend:ci -o spdx-json > mooncode-backend.spdx.json + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock anchore/syft:v1.33.0 mooncode-frontend:ci -o spdx-json > mooncode-frontend.spdx.json + + - name: Upload SBOMs + uses: actions/upload-artifact@v4 + with: + name: mooncode-image-sboms + path: mooncode-*.spdx.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b3d5791 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,196 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + verify: + name: Verify release source + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Validate semantic version tag + shell: bash + run: | + if [[ ! "${GITHUB_REF_NAME}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then + echo "Release tag must be a semantic version such as v1.2.3 or v1.2.3-rc.1" >&2 + exit 1 + fi + version_without_build="${GITHUB_REF_NAME%%+*}" + if [[ "${version_without_build}" == *-* ]]; then + prerelease="${version_without_build#*-}" + IFS='.' read -ra identifiers <<< "${prerelease}" + for identifier in "${identifiers[@]}"; do + if [[ "${identifier}" =~ ^[0-9]+$ && "${identifier}" == 0* && "${identifier}" != "0" ]]; then + echo "Numeric prerelease identifiers must not contain leading zeroes" >&2 + exit 1 + fi + done + fi + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci --prefix frontend + + - name: Verify generated and module files + run: | + go mod tidy + make generate/sqlc + git diff --exit-code + + - name: Lint Go + uses: golangci/golangci-lint-action@v9.3.0 + with: + version: v2.12.2 + args: ./api/... ./cmd/... ./internal/... ./pkg/... ./migrations/... + + - name: Run release checks + run: make test vet frontend-i18n frontend-lint frontend-build + + - name: Start PostgreSQL + run: >- + docker run --rm -d --name mooncode-release-postgres + -e POSTGRES_USER=mooncode + -e POSTGRES_PASSWORD=mooncode + -e POSTGRES_DB=mooncode + -p 5432:5432 postgres:17-bookworm + + - name: Wait for PostgreSQL + run: | + for attempt in $(seq 1 30); do + docker exec mooncode-release-postgres pg_isready -U mooncode -d mooncode && exit 0 + sleep 1 + done + exit 1 + + - name: Run integration tests + env: + MOONCODE_TEST_DATABASE_URL: postgres://mooncode:mooncode@127.0.0.1:5432/mooncode?sslmode=disable + run: make integration-test + + images: + name: Publish ${{ matrix.component }} image + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + packages: write + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + include: + - component: backend + dockerfile: deploy/backend.Dockerfile + - component: frontend + dockerfile: deploy/frontend.Dockerfile + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set build time + id: build-time + run: echo "value=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image name + id: image + shell: bash + run: | + echo "name=ghcr.io/${GITHUB_REPOSITORY,,}/${{ matrix.component }}" >> "${GITHUB_OUTPUT}" + version_without_build="${GITHUB_REF_NAME%%+*}" + if [[ "${version_without_build}" == *-* ]]; then + echo "latest=false" >> "${GITHUB_OUTPUT}" + else + echo "latest=true" >> "${GITHUB_OUTPUT}" + fi + + - name: Compute image metadata + id: metadata + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ steps.image.outputs.latest }} + + - name: Build and publish image + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} + BUILD_TIME=${{ steps.build-time.outputs.value }} + cache-from: type=gha,scope=${{ matrix.component }} + cache-to: type=gha,mode=max,scope=${{ matrix.component }} + provenance: mode=max + sbom: true + + - name: Install Cosign + uses: sigstore/cosign-installer@v3.9.2 + + - name: Sign published image + env: + DIGEST: ${{ steps.build.outputs.digest }} + IMAGE: ${{ steps.image.outputs.name }} + run: cosign sign --yes "${IMAGE}@${DIGEST}" + + - name: Attest image provenance + uses: actions/attest-build-provenance@v3 + with: + subject-name: ${{ steps.image.outputs.name }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + + github-release: + name: Publish GitHub release + needs: images + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create release notes + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/Makefile b/Makefile index 436ffb5..6850609 100644 --- a/Makefile +++ b/Makefile @@ -2,27 +2,31 @@ GO ?= go GOFLAGS ?= -buildvcs=false NPM ?= npm DOCKER ?= docker -GOCACHE ?= /tmp/mooncode-go-cache -GOMODCACHE ?= /tmp/mooncode-mod-cache -GO_TOOL_PATH ?= /tmp/mooncode-go-tools -GOLANGCI_LINT_CACHE ?= /tmp/mooncode-golangci-lint-cache +GOCACHE ?= $(CURDIR)/../../cache/go-build +GOMODCACHE ?= $(CURDIR)/../../cache/go-mod +GO_TOOL_PATH ?= $(CURDIR)/../../cache/go-path +GOLANGCI_LINT_CACHE ?= $(CURDIR)/../../cache/golangci-lint SQLC_VERSION ?= v1.29.0 +SQLC ?= $(CURDIR)/../../cache/bin/sqlc-$(SQLC_VERSION) COMPOSE_FILES = --env-file deploy/.env -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml -GO_PACKAGES = ./cmd/... ./internal/... ./pkg/... ./migrations/... +GO_PACKAGES = ./api/... ./cmd/... ./internal/... ./pkg/... ./migrations/... -.PHONY: test integration-test vet lint frontend-build frontend-i18n frontend-lint generate generate/sqlc compose-dev-up compose-dev-down check +.PHONY: build test integration-test vet lint frontend-build frontend-i18n frontend-lint generate generate/sqlc compose-dev-up compose-dev-down e2e-live check + +build: + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) build ./cmd/mooncode test: - GOCACHE=$(GOCACHE) GOFLAGS=$(GOFLAGS) $(GO) test $(GO_PACKAGES) + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) test $(GO_PACKAGES) integration-test: - GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) test -tags=integration -count=1 ./internal/repository/postgres ./pkg/storage/s3 ./pkg/git + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) test -tags=integration -count=1 ./internal/identity/data ./internal/repository/data ./internal/analysis/data ./internal/channel/data ./internal/retention/data ./internal/platform/audit ./internal/workflow/data ./pkg/gitrepo vet: - GOCACHE=$(GOCACHE) GOFLAGS=$(GOFLAGS) $(GO) vet $(GO_PACKAGES) + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) vet $(GO_PACKAGES) lint: - GOCACHE=$(GOCACHE) GOLANGCI_LINT_CACHE=$(GOLANGCI_LINT_CACHE) GOFLAGS=$(GOFLAGS) golangci-lint run $(GO_PACKAGES) + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOLANGCI_LINT_CACHE=$(GOLANGCI_LINT_CACHE) GOFLAGS=$(GOFLAGS) golangci-lint run $(GO_PACKAGES) frontend-build: $(NPM) --prefix frontend run build @@ -36,7 +40,11 @@ frontend-i18n: generate: generate/sqlc generate/sqlc: - GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOPATH=$(GO_TOOL_PATH) $(GO) run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) generate + @if [ -x "$(SQLC)" ]; then \ + "$(SQLC)" generate; \ + else \ + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOPATH=$(GO_TOOL_PATH) $(GO) run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) generate; \ + fi compose-dev-up: $(DOCKER) compose $(COMPOSE_FILES) up -d --build --wait @@ -44,4 +52,7 @@ compose-dev-up: compose-dev-down: $(DOCKER) compose $(COMPOSE_FILES) down -check: test vet lint frontend-i18n frontend-lint frontend-build +e2e-live: + ./scripts/e2e/live-smoke.sh + +check: build test vet lint frontend-i18n frontend-lint frontend-build diff --git a/README.md b/README.md index f073a38..1e53635 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,50 @@
MoonCode Logo

MoonCode

-

面向团队的 AI 代码质量评估平台

+

面向团队的 AI 代码质量分析 SaaS 平台

+

English · 简体中文

- English · 简体中文 -

-

- Go 1.25 + Go 1.23 React 19 CI

-MoonCode 将代码仓库与团队日常使用的 IM 工具连接起来,为后续的 AI 代码分析、质量评估和协作反馈提供统一平台。 +MoonCode 管理代码仓库的不可变 commit 快照,使用 `scc` 生成基础质量指标,并通过飞书或钉钉接收团队消息、发送分析完成通知。当前代码仍处于首个正式版本之前,不包含旧 API、旧类型或迁移兼容层。 + +## 核心能力 + +- 认证:由 Tinyauth v5 完成 GitHub OAuth 登录和会话管理;MoonCode 不保存密码,也不实现密码登录。 +- 多租户:Workspace 是业务隔离与 RBAC 边界,支持 `owner`、`admin`、`member`。 +- 代码托管凭证:GitHub/GitLab PAT 是当前用户的个人 Provider Connection,不属于 Workspace;管理员不能查看或代用他人的 PAT。 +- 仓库:用户只需提交仓库地址和 `ref`;公共 GitHub/GitLab 仓库通过匿名 HTTPS 同步,私有仓库可选用当前用户自己的 Provider Connection。 +- 快照:每个 Repository 对应一个受控 bare Git 对象库。同步只允许受控的 heads/tags refspec,解析后的 commit 固定在 `refs/mooncode/snapshots/`,历史快照不会随远端分支移动。 +- 分析:分析任务关联确定的 Commit Snapshot,由 Hatchet Worker checkout 固定 commit 后运行可替换的 Analyzer;首版默认使用 `scc`。 +- Channel:飞书使用 WebSocket,钉钉使用 Stream Mode。runtime 统一执行发送者 allowlist、群聊 mention/prefix 策略、消息归一化和幂等入库;分析成功后通过 App API 发送通知。 +- 异步可靠性:Repository、Analysis、Notification 和数据保留清理都由 Hatchet Workflow/Task 执行;事务性 PostgreSQL outbox 负责弥合业务提交与 Hatchet 派发之间的崩溃窗口,没有本地任务 Worker fallback。 +- 数据保留:Workspace 可配置 1–3650 天的分析保留期;Hatchet 定期删除到期终态 Run/Report/Notification、幂等释放 Snapshot pin,并在引用归零后重新派发 Repository purge。 + +本地查看仓库、分析已有快照、删除 Repository,以及同步公共仓库都不需要 PAT。私有仓库的创建、刷新或来源修改使用发起用户显式选择的个人 PAT;任务会固化连接 ID 和凭据版本,PAT 更换后旧排队任务会拒绝执行。 + +## 技术栈与进程角色 -> 当前版本已经完成登录注册、租户与权限、仓库同步、IM Channel、任务调度和可观测性等基础能力;AI 评估能力将继续迭代。 +- 后端:Go 1.23、Gin、Cobra、Viper、pgx/v5、sqlc +- 工作流:Hatchet v0.98.9,V1 engine +- 数据库:PostgreSQL 17;MoonCode 与 Hatchet 使用独立 database 和 role +- 认证:Tinyauth v5.1.2 + GitHub OAuth +- 前端:React 19、TypeScript、Vite、Tailwind CSS +- IM SDK:Lark/Feishu OpenAPI SDK、DingTalk Stream SDK -## 主要能力 +同一个 `mooncode` 二进制提供两个明确分离的角色: -- 使用 tinyauth 和 GitHub OAuth 登录,MoonCode 负责用户注册、Workspace 与成员权限。 -- 支持 GitHub、GitLab 和通用 Git 仓库,包括用户私有仓库。 -- 每个 Repository 只保留用户指定 `ref` 对应的当前 commit,不维护源码快照历史。 -- 使用可注册的 Channel 模型接入飞书、钉钉,并支持继续扩展其他 IM 平台。 -- 单一 Go 后端进程,内部按业务模块组织,不拆分 API 或 Worker 服务。 -- 提供 Prometheus 指标、OpenTelemetry Trace、结构化日志、审计日志和健康检查。 -- React 19 前端,支持简体中文、英文与开发环境热更新。 +- `mooncode serve`:HTTP API、Tinyauth ForwardAuth 身份消费、飞书/钉钉长连接。 +- `mooncode worker`:workflow outbox dispatcher 与 Hatchet Worker;只有该角色执行远程 Git、代码分析和保留期清理。 -## 技术栈 +API 与 Worker 共享 PostgreSQL 和 Repository 持久卷。首版要求只有一个 Git 存储节点。 -- 后端:Go 1.25、Gin、Cobra、Viper、dig、pgx、sqlc -- 前端:React 19、TypeScript、Vite、Tailwind CSS、shadcn 风格组件 -- 基础设施:PostgreSQL 17、tinyauth、Traefik、Docker Compose -- 可观测性:Prometheus、OpenTelemetry、zerolog +Compose 将 PostgreSQL/Hatchet 放在内部 backend 网络;Worker 额外连接不发布任何端口的 outbound 网络,用于访问 GitHub/GitLab。数据库和 Worker 指标端口不会因此暴露到宿主机。 + +两个进程分别暴露 Prometheus 指标:API 在自身 HTTP 地址提供 `/metrics`,Worker 在独立的私有监听地址(默认 `:9090`)提供 `/metrics` 和 `/healthz`。Compose 中可从后端网络访问 `http://mooncode-api:8080/metrics` 与 `http://mooncode-worker:9090/metrics`;这些端点不会通过公共 Traefik 路由暴露。 ## 快速开始 @@ -39,39 +52,49 @@ MoonCode 将代码仓库与团队日常使用的 IM 工具连接起来,为后 - Docker 与 Docker Compose - 一个 GitHub OAuth App -- 开发机端口 `3100` 可用 +- 本机端口 `3100` 可用 -GitHub OAuth App 的 Callback URL: +GitHub OAuth App Callback URL: ```text -http://auth.mooncode.localhost:3100/api/oauth/callback/github +http://mooncode.localhost:3100/api/oauth/callback/github ``` -### 启动开发环境 +Tinyauth 的 OAuth API 通过 MoonCode 同域暴露:`/api/oauth/*` 与 `/api/user/logout` 由 Traefik 转发给 Tinyauth,`/api/v1/*` 转发给 MoonCode API。Tinyauth 使用仅限 `mooncode.localhost` 的 host-only session Cookie,不启用跨子域 Cookie。 + +准备配置: ```bash cp .env.example deploy/.env mkdir -p deploy/secrets +openssl rand -base64 32 # 分别填入两个 MoonCode key,必须使用不同值 +printf '%s' '' > deploy/secrets/github_oauth_client_secret ``` -然后: +在 `deploy/.env` 中填写: + +- `MOONCODE_SECRETS_KEY` +- `MOONCODE_AUTH_CSRF_KEY` +- `GITHUB_OAUTH_CLIENT_ID` -1. 在 `deploy/.env` 中填写 `GITHUB_OAUTH_CLIENT_ID` 和按需配置的服务代理。 -2. 将 OAuth Client Secret 写入 `deploy/secrets/github_oauth_client_secret`。 -3. 启动服务: +首次使用自托管 Hatchet 时,先启动基础服务: + +```bash +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d postgres traefik hatchet +``` + +访问 完成 Hatchet 初始化,创建 MoonCode client token,并把它写入 `deploy/.env` 的 `HATCHET_CLIENT_TOKEN`。随后启动完整开发环境: ```bash make compose-dev-up ``` -访问地址: +访问: - MoonCode: -- 登录服务: - -开发 Compose 会为前端启用 Vite HMR,修改 React 或 CSS 文件无需重新构建镜像。 +- Hatchet: -### 停止开发环境 +停止环境: ```bash make compose-dev-down @@ -79,62 +102,84 @@ make compose-dev-down ## 本地开发 +项目源码必须位于工作区根目录的 `worktree/` 下。可跨 worktree 共享的数据和缓存分别放在根目录 `data/`、`cache/`,不得提交。 + ```bash -# 安装前端依赖 npm ci --prefix frontend -# 运行全部静态检查、单元测试和前端构建 -make check +make generate/sqlc +make test +make vet +make frontend-i18n frontend-lint frontend-build +``` -# 运行 PostgreSQL、S3 Adapter 和 TLS Git 集成测试 -make integration-test +运行 PostgreSQL 与 Git 集成测试: -# 重新生成 sqlc 代码 -make generate/sqlc +```bash +MOONCODE_TEST_DATABASE_URL='postgres://mooncode:mooncode@127.0.0.1:5432/mooncode?sslmode=disable' \ + make integration-test ``` -单独运行后端: +对已经完成 GitHub OAuth 登录的真实部署运行只读 smoke test: ```bash -go run ./cmd/mooncode serve --config mooncode.example.yaml +MOONCODE_E2E_BASE_URL='https://mooncode.example.com' \ +MOONCODE_E2E_SESSION_COOKIE='tinyauth=' \ + make e2e-live ``` -单独运行前端: +该检查只发送 `GET` 请求,依次验证 `/healthz`、Tinyauth 认证后的 `/api/v1/session` 和 `/api/openapi.yaml`,不会伪造 `Remote-*` 身份头。没有真实 OAuth 会话时可以跳过此项。 + +直接运行单个角色: ```bash -npm --prefix frontend run dev +go run ./cmd/mooncode serve --config mooncode.example.yaml +go run ./cmd/mooncode worker --config mooncode.example.yaml +go run ./cmd/mooncode version ``` -## 仓库结构 +## 目录结构 ```text -cmd/mooncode/ 命令行入口 -internal/ MoonCode 内部业务实现 -pkg/ 可被第三方独立复用的扩展接口与默认实现 -migrations/ 唯一初始数据库 Migration -sql/ sqlc 查询定义 -frontend/ React 前端 -deploy/ Dockerfile 与 Compose 配置 -.github/workflows/ GitHub Actions CI +cmd/mooncode/ Cobra 命令入口 +internal//biz/ 业务模型与用例 +internal//data/ PostgreSQL/sqlc 实现 +internal//workflow/ Hatchet Task Runner +internal/channel/runtime/ 长连接生命周期、策略与入站编排 +internal/platform/ HTTP、认证、数据库、Hatchet、加密装配 +pkg/ 可被第三方独立使用的接口与默认实现 +migrations/ 唯一初始 PostgreSQL migration +sql/queries/ sqlc 查询定义 +frontend/ React 前端 +deploy/ Dockerfile 与 Compose ``` -## 配置与敏感信息 +## 安全边界 -- `.env.example` 和 `mooncode.example.yaml` 只包含公开的开发示例值。 -- `deploy/.env`、`deploy/secrets/`、私钥、证书和本地配置均被 `.gitignore` 排除。 -- Docker build context 同样排除了上述文件,避免将本地凭据发送给构建器。 -- 生产环境必须覆盖示例中的 Gateway Token、SecretStore Master Key、CSRF Key 和数据库凭据。 -- 不要将 OAuth Client Secret、Git 访问令牌或 IM App Secret 写入源码和 Compose 文件。 +- Traefik 在 ForwardAuth 前删除客户端伪造的 `Remote-*` 头,只转发 Tinyauth 返回的身份头。 +- 改写请求使用 HMAC CSRF token。 +- PAT 与 Channel secret 使用 AES-GCM 加密,API 永不返回明文。 +- Provider Connection 查询始终带当前 `user_id`;Workspace owner 没有读取或使用成员 PAT 的路径。 +- 首版只允许 `https://github.com` 与 `https://gitlab.com`,避免任意 Provider base URL 造成 SSRF。 +- Git 凭据通过单次命令的临时 HTTP authorization header 提供,不写入 Repository remote URL 或 Git 配置。 +- Git 与 Analyzer 子进程使用最小环境,只按需保留网络代理,不继承数据库 URL、Hatchet Token 或主密钥。 +- MoonCode API/Worker 容器使用非 root、只读根文件系统、`no-new-privileges` 和最小 capability。 ## CI -GitHub Actions 会在 Push 和 Pull Request 时执行: +GitHub Actions 会校验: -- Go 单元测试、`go vet` 与 golangci-lint -- 前端 i18n 检查、ESLint 与生产构建 -- PostgreSQL、S3 Adapter、TLS Git 集成测试 +- `go mod tidy` 和 sqlc 生成结果无差异 +- golangci-lint、Go 单元测试与 `go vet` +- PostgreSQL、事务性 outbox 和受控 bare Git 集成测试 +- 前端 i18n、ESLint 与生产构建 - gitleaks 敏感信息扫描 +- PR 依赖变更审查、Go `govulncheck` 与 npm 高危漏洞审计 +- 后端/前端镜像构建及 Trivy `HIGH`、`CRITICAL` 漏洞扫描 +- 使用 Syft 生成并上传两个镜像的 SPDX JSON SBOM + +推送严格 SemVer 格式的 `v*` Tag 后,Release 工作流会重新执行生成校验、单元/集成测试和前端构建,随后向 GHCR 发布后端与前端的 `linux/amd64`、`linux/arm64` 镜像。预发布版本不会覆盖 `latest`。镜像携带版本、commit 和构建时间,附带 OCI SBOM/构建来源证明,并使用 GitHub OIDC + Cosign 无密钥签名;全部镜像成功后自动生成 GitHub Release。 ## 已知限制 -飞书官方 Go SDK v3.9.9 的 `Start` 在 `Close` 后不会返回。MoonCode 停用 Channel 时会关闭连接并停止接收新消息,但对应 SDK goroutine 只能在后端进程退出时回收。频繁停用、启用或热更新飞书 Channel 后,建议重启后端容器。 +飞书官方 Go SDK v3.9.4 的 `Start` 在连接 context 取消后仍不会返回。MoonCode 会关闭实际连接并停止接收入站消息,但 SDK 的等待 goroutine 只能随 API 进程退出回收。频繁停用、启用或热更新飞书 Channel 后,建议重启 API 容器。 diff --git a/README_EN.md b/README_EN.md index 33f634e..2642604 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,37 +1,50 @@
MoonCode Logo

MoonCode

-

An AI code quality assessment platform for teams

+

An AI code quality analysis SaaS platform for teams

+

English · 简体中文

- English · 简体中文 -

-

- Go 1.25 + Go 1.23 React 19 CI

-MoonCode connects source repositories with the IM tools a team already uses, providing one platform for AI-assisted code analysis, quality assessment, and collaborative feedback. +MoonCode manages immutable commit snapshots, produces baseline quality metrics with `scc`, receives team messages through Feishu or DingTalk, and sends analysis completion notifications. The codebase is still before its first release and contains no legacy API, type, or migration compatibility layer. + +## Core capabilities + +- Authentication: Tinyauth v5 owns GitHub OAuth login and sessions. MoonCode stores no passwords and implements no password login. +- Multi-tenancy: Workspace is the data isolation and RBAC boundary, with `owner`, `admin`, and `member` roles. +- Source credentials: GitHub/GitLab PATs are personal Provider Connections owned by the current user, never Workspace assets. Administrators cannot read or impersonate another user's PAT. +- Repositories: users only submit a repository URL and a `ref`. Public GitHub/GitLab repositories synchronize over anonymous HTTPS, while private repositories can use the current user's own Provider Connection. +- Snapshots: each Repository owns a managed bare Git object store. Fetches use controlled heads/tags refspecs, and each resolved commit is pinned at `refs/mooncode/snapshots/` so historical snapshots never move with a remote branch. +- Analysis: every run points to a specific Commit Snapshot. A Hatchet Worker checks out that commit and invokes a replaceable Analyzer; the first implementation uses `scc`. +- Channels: Feishu uses WebSocket and DingTalk uses Stream Mode. The runtime applies a common sender allowlist and group mention/prefix policy, normalizes and deduplicates inbound messages, and sends completion notifications through the platform App APIs. +- Reliable async work: Repository, Analysis, Notification, and retention cleanup all run as Hatchet workflows/tasks. A transactional PostgreSQL outbox closes the crash window between business commits and Hatchet dispatch. There is no local task-worker fallback. +- Data retention: each Workspace can keep analysis data for 1–3650 days. Hatchet removes expired terminal Runs/Reports/Notifications, releases Snapshot pins idempotently, and redispatches Repository purge after references reach zero. + +Viewing local data, analyzing an existing snapshot, deleting a Repository, and synchronizing a public repository do not need a PAT. Creating, refreshing, or changing the source of a private repository uses the initiating user's explicitly selected personal PAT. Queued operations freeze the connection ID and credential version, so rotating a PAT invalidates older queued work. + +## Technology and process roles -> The current release implements the platform foundation: authentication and registration, tenancy and authorization, repository synchronization, IM channels, job processing, and observability. AI assessment capabilities will be added incrementally. +- Backend: Go 1.23, Gin, Cobra, Viper, pgx/v5, and sqlc +- Workflows: Hatchet v0.98.9 with the V1 engine +- Database: PostgreSQL 17; MoonCode and Hatchet use separate databases and roles +- Authentication: Tinyauth v5.1.2 with GitHub OAuth +- Frontend: React 19, TypeScript, Vite, and Tailwind CSS +- IM SDKs: Lark/Feishu OpenAPI SDK and DingTalk Stream SDK -## Features +The same `mooncode` binary provides two strictly separated roles: -- GitHub OAuth login through tinyauth, with MoonCode-owned registration, workspaces, and member authorization. -- GitHub, GitLab, and generic Git repositories, including private repositories. -- One current commit checkout per Repository and no source snapshot history. -- A registry-based Channel model for Feishu and DingTalk, designed for additional IM providers. -- One Go backend process organized as internal business modules, without separate API or worker services. -- Prometheus metrics, OpenTelemetry tracing, structured logs, audit logs, and health checks. -- A React 19 frontend with Simplified Chinese, English, and Vite HMR in development. +- `mooncode serve`: HTTP API, Tinyauth ForwardAuth identity consumption, and Feishu/DingTalk long-lived connections. +- `mooncode worker`: transactional workflow dispatcher and Hatchet Worker. Only this role performs remote Git operations, code analysis, and retention cleanup. -## Technology +API and Worker share PostgreSQL and the Repository persistent volume. The first release assumes a single Git storage node. -- Backend: Go 1.25, Gin, Cobra, Viper, dig, pgx, and sqlc -- Frontend: React 19, TypeScript, Vite, Tailwind CSS, and shadcn-style components -- Infrastructure: PostgreSQL 17, tinyauth, Traefik, and Docker Compose -- Observability: Prometheus, OpenTelemetry, and zerolog +Compose keeps PostgreSQL and Hatchet on the internal backend network. The Worker also joins an outbound-only network with no published ports so it can reach GitHub and GitLab; neither the database nor Worker metrics become host-accessible. + +The processes expose separate Prometheus endpoints. The API serves `/metrics` on its HTTP listener, while the Worker serves `/metrics` and `/healthz` on a private listener (default `:9090`). In Compose they are reachable from the backend network at `http://mooncode-api:8080/metrics` and `http://mooncode-worker:9090/metrics`; neither endpoint is published through the public Traefik routes. ## Quick start @@ -39,26 +52,38 @@ MoonCode connects source repositories with the IM tools a team already uses, pro - Docker and Docker Compose - A GitHub OAuth App -- Port `3100` available on the development host +- Port `3100` available on the host -Configure this callback URL in the GitHub OAuth App: +Configure this GitHub OAuth callback URL: ```text -http://auth.mooncode.localhost:3100/api/oauth/callback/github +http://mooncode.localhost:3100/api/oauth/callback/github ``` -### Start the development stack +Traefik exposes Tinyauth's OAuth API on the MoonCode origin: `/api/oauth/*` and `/api/user/logout` go to Tinyauth, while `/api/v1/*` goes to the MoonCode API. Tinyauth uses a host-only session cookie for `mooncode.localhost`; cross-subdomain cookies stay disabled. + +Prepare configuration: ```bash cp .env.example deploy/.env mkdir -p deploy/secrets +openssl rand -base64 32 # generate two distinct MoonCode keys +printf '%s' '' > deploy/secrets/github_oauth_client_secret ``` -Then: +Set these values in `deploy/.env`: + +- `MOONCODE_SECRETS_KEY` +- `MOONCODE_AUTH_CSRF_KEY` +- `GITHUB_OAUTH_CLIENT_ID` -1. Set `GITHUB_OAUTH_CLIENT_ID` and any service-scoped proxies in `deploy/.env`. -2. Write the OAuth Client Secret to `deploy/secrets/github_oauth_client_secret`. -3. Start the stack: +For the first self-hosted Hatchet bootstrap, start the infrastructure services: + +```bash +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d postgres traefik hatchet +``` + +Open , complete Hatchet initialization, create a MoonCode client token, and set it as `HATCHET_CLIENT_TOKEN` in `deploy/.env`. Then start the full development stack: ```bash make compose-dev-up @@ -67,74 +92,94 @@ make compose-dev-up Open: - MoonCode: -- Authentication: - -The development Compose override enables Vite HMR, so React and CSS changes do not require an image rebuild. +- Hatchet: -### Stop the development stack +Stop the stack with: ```bash make compose-dev-down ``` -## Development +## Local development + +Source worktrees belong under the workspace root's `worktree/` directory. Cross-worktree data and caches belong under the root `data/` and `cache/` directories and must never be committed. ```bash -# Install frontend dependencies npm ci --prefix frontend -# Run static checks, unit tests, and the frontend production build -make check +make generate/sqlc +make test +make vet +make frontend-i18n frontend-lint frontend-build +``` -# Run PostgreSQL, S3 adapter, and TLS Git integration tests -make integration-test +Run PostgreSQL and Git integration tests: -# Regenerate sqlc code -make generate/sqlc +```bash +MOONCODE_TEST_DATABASE_URL='postgres://mooncode:mooncode@127.0.0.1:5432/mooncode?sslmode=disable' \ + make integration-test ``` -Run the backend directly: +Run the read-only smoke test against a live deployment after completing GitHub OAuth: ```bash -go run ./cmd/mooncode serve --config mooncode.example.yaml +MOONCODE_E2E_BASE_URL='https://mooncode.example.com' \ +MOONCODE_E2E_SESSION_COOKIE='tinyauth=' \ + make e2e-live ``` -Run the frontend directly: +The check only sends `GET` requests to `/healthz`, the Tinyauth-authenticated `/api/v1/session`, and `/api/openapi.yaml`. It never fabricates `Remote-*` identity headers. Skip it when no live OAuth session is available. + +Run each role directly: ```bash -npm --prefix frontend run dev +go run ./cmd/mooncode serve --config mooncode.example.yaml +go run ./cmd/mooncode worker --config mooncode.example.yaml +go run ./cmd/mooncode version ``` ## Repository layout ```text -cmd/mooncode/ CLI entry point -internal/ MoonCode-specific business implementation -pkg/ Reusable extension interfaces and default implementations -migrations/ The single initial database migration -sql/ sqlc query definitions -frontend/ React frontend -deploy/ Dockerfiles and Compose configuration -.github/workflows/ GitHub Actions CI +cmd/mooncode/ Cobra entry point +internal//biz/ Business models and use cases +internal//data/ PostgreSQL/sqlc implementations +internal//workflow/ Hatchet task runners +internal/channel/runtime/ Connection lifecycle, policy, and inbound orchestration +internal/platform/ HTTP, auth, database, Hatchet, and encryption assembly +pkg/ Independently reusable interfaces and defaults +migrations/ The single initial PostgreSQL migration +sql/queries/ sqlc query definitions +frontend/ React frontend +deploy/ Dockerfiles and Compose ``` -## Configuration and secrets +## Security boundaries -- `.env.example` and `mooncode.example.yaml` contain public development examples only. -- `deploy/.env`, `deploy/secrets/`, private keys, certificates, and local configuration are excluded by `.gitignore`. -- The same files are excluded from the Docker build context, preventing local credentials from being sent to a builder. -- Production deployments must override the example Gateway Token, SecretStore Master Key, CSRF Key, and database credentials. -- Never place OAuth client secrets, Git access tokens, or IM app secrets in source files or Compose configuration. +- Traefik strips client-supplied `Remote-*` headers before ForwardAuth and forwards only Tinyauth response headers. +- Mutating requests require an HMAC CSRF token. +- PATs and Channel secrets are encrypted with AES-GCM and never returned by the API. +- Provider Connection queries always include the current `user_id`; Workspace owners have no path to read or use member PATs. +- The first release only accepts `https://github.com` and `https://gitlab.com`, preventing arbitrary Provider base URLs from becoming an SSRF primitive. +- Git credentials are exposed through a per-command temporary HTTP authorization header and are never written into Repository remote URLs or Git configuration. +- Git and Analyzer subprocesses run with a minimal environment. Only required network proxy settings are retained; database URLs, Hatchet tokens, and master keys are not inherited. +- The MoonCode API/Worker containers run non-root with a read-only root filesystem, `no-new-privileges`, and minimal capabilities. ## CI -GitHub Actions runs the following checks on pushes and pull requests: +GitHub Actions verifies: -- Go unit tests, `go vet`, and golangci-lint -- Frontend i18n checks, ESLint, and a production build -- PostgreSQL, S3 adapter, and TLS Git integration tests +- zero diff after `go mod tidy` and sqlc generation +- golangci-lint, Go unit tests, and `go vet` +- PostgreSQL, transactional outbox, and managed bare Git integration tests +- frontend i18n, ESLint, and production build - gitleaks secret scanning +- pull-request dependency review, Go `govulncheck`, and high-severity npm audit +- backend/frontend image builds and Trivy `HIGH`/`CRITICAL` vulnerability scans +- SPDX JSON SBOM generation and artifact upload for both images with Syft + +Pushing a strict SemVer `v*` tag starts the Release workflow. It repeats generation checks, unit/integration tests, and the frontend build, then publishes backend and frontend images for `linux/amd64` and `linux/arm64` to GHCR. Prereleases do not overwrite `latest`. Images carry version, commit, and build-time metadata, include OCI SBOM/provenance attestations, and are keylessly signed with GitHub OIDC and Cosign. A GitHub Release is created after both images publish successfully. ## Known limitation -The official Feishu Go SDK v3.9.9 does not return from `Start` after `Close`. MoonCode closes the connection and stops inbound admission when a Channel is disabled, but the SDK goroutine is reclaimed only when the backend process exits. Restart the backend container after repeatedly disabling, enabling, or hot-updating a Feishu Channel. +The official Feishu Go SDK v3.9.4 does not return from `Start` after its connection context is cancelled. MoonCode closes the actual connection and stops admitting inbound messages, but the SDK's waiting goroutine is reclaimed only when the API process exits. Restart the API container after repeatedly disabling, enabling, or hot-updating Feishu Channels. diff --git a/api/openapi/mooncode.yaml b/api/openapi/mooncode.yaml new file mode 100644 index 0000000..085b83c --- /dev/null +++ b/api/openapi/mooncode.yaml @@ -0,0 +1,1172 @@ +openapi: 3.1.0 +info: + title: MoonCode API + version: 0.1.0 + description: | + API for personal provider credentials, workspace repositories, immutable Git commit + snapshots, code-quality analyses, and Feishu/DingTalk channels. Authentication is + performed by Tinyauth. Identity headers are accepted only from configured trusted + reverse-proxy peers. Every mutating request also requires the CSRF header issued by + `GET /api/v1/csrf`. Except for session, CSRF, onboarding, and invitation acceptance, + SaaS endpoints require the local MoonCode account to be `active`; an authenticated + `pending` account receives `403 account.onboarding_required`. +servers: + - url: / +tags: + - name: System + - name: Identity + - name: Providers + - name: Repositories + - name: Analysis + - name: Channels +security: + - tinyauthSession: [] +paths: + /healthz: + get: + tags: [System] + operationId: health + security: [] + responses: + '200': { $ref: '#/components/responses/Health' } + /readyz: + get: + tags: [System] + operationId: readiness + security: [] + responses: + '200': { $ref: '#/components/responses/Ready' } + '503': { $ref: '#/components/responses/NotReady' } + /metrics: + get: + tags: [System] + operationId: metrics + security: [] + description: Prometheus text exposition endpoint intended for the private service network. + responses: + '200': + description: Prometheus metrics + content: + text/plain: + schema: { type: string } + /api/openapi.yaml: + get: + tags: [System] + operationId: openapi + security: [] + responses: + '200': + description: This OpenAPI document + content: + application/yaml: + schema: { type: string } + /api/v1/csrf: + get: + tags: [Identity] + operationId: issueCSRF + responses: + '200': + description: CSRF token bound to the authenticated user + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string } + '401': { $ref: '#/components/responses/Error' } + /api/v1/session: + get: + tags: [Identity] + operationId: session + responses: + '200': + description: Current user session + content: + application/json: + schema: { $ref: '#/components/schemas/Session' } + '401': { $ref: '#/components/responses/Error' } + /api/v1/onboarding/complete: + post: + tags: [Identity] + operationId: completeOnboarding + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [acceptTerms, acceptPrivacy] + properties: + acceptTerms: { type: boolean } + acceptPrivacy: { type: boolean } + responses: + '200': { $ref: '#/components/responses/Session' } + '400': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + /api/v1/invitations/accept: + post: + tags: [Identity] + operationId: acceptInvitation + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, acceptTerms, acceptPrivacy] + properties: + token: { type: string, writeOnly: true } + acceptTerms: { type: boolean } + acceptPrivacy: { type: boolean } + responses: + '200': { $ref: '#/components/responses/Session' } + '400': { $ref: '#/components/responses/Error' } + /api/v1/workspaces: + post: + tags: [Identity] + operationId: createWorkspace + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string, minLength: 1 } + responses: + '201': { $ref: '#/components/responses/Workspace' } + '400': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}: + parameters: [{ $ref: '#/components/parameters/WorkspaceID' }] + patch: + tags: [Identity] + operationId: updateWorkspace + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: { type: string, minLength: 1 } + reportRetentionDays: { type: integer, minimum: 1, maximum: 3650 } + responses: + '200': { $ref: '#/components/responses/Workspace' } + '403': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/members: + parameters: [{ $ref: '#/components/parameters/WorkspaceID' }] + get: + tags: [Identity] + operationId: listWorkspaceMembers + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/MemberPage' } + '403': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/members/{userID}: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/UserID' } + delete: + tags: [Identity] + operationId: removeWorkspaceMember + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '204': { $ref: '#/components/responses/NoContent' } + '403': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/invitations: + parameters: [{ $ref: '#/components/parameters/WorkspaceID' }] + get: + tags: [Identity] + operationId: listWorkspaceInvitations + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/InvitationPage' } + '403': { $ref: '#/components/responses/Error' } + post: + tags: [Identity] + operationId: createWorkspaceInvitation + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + responses: + '201': { $ref: '#/components/responses/Invitation' } + '400': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/invitations/{invitationID}: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/InvitationID' } + delete: + tags: [Identity] + operationId: revokeWorkspaceInvitation + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '204': { $ref: '#/components/responses/NoContent' } + '404': { $ref: '#/components/responses/Error' } + /api/v1/me/provider-connections: + get: + tags: [Providers] + operationId: listProviderConnections + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/ProviderConnectionPage' } + post: + tags: [Providers] + operationId: createProviderConnection + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ProviderCredentialInput' } + responses: + '201': { $ref: '#/components/responses/ProviderConnection' } + '400': { $ref: '#/components/responses/Error' } + /api/v1/me/provider-connections/{connectionID}: + parameters: [{ $ref: '#/components/parameters/ConnectionID' }] + patch: + tags: [Providers] + operationId: rotateProviderCredential + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string, writeOnly: true, minLength: 1 } + responses: + '200': { $ref: '#/components/responses/ProviderConnection' } + '404': { $ref: '#/components/responses/Error' } + delete: + tags: [Providers] + operationId: revokeProviderConnection + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '204': { $ref: '#/components/responses/NoContent' } + '404': { $ref: '#/components/responses/Error' } + /api/v1/me/provider-connections/{connectionID}/test: + parameters: [{ $ref: '#/components/parameters/ConnectionID' }] + post: + tags: [Providers] + operationId: testProviderConnection + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '200': { $ref: '#/components/responses/ProviderConnection' } + '400': { $ref: '#/components/responses/Error' } + /api/v1/me/provider-connections/{connectionID}/default: + parameters: [{ $ref: '#/components/parameters/ConnectionID' }] + post: + tags: [Providers] + operationId: setDefaultProviderConnection + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '200': { $ref: '#/components/responses/ProviderConnection' } + /api/v1/workspaces/{workspaceID}/repositories: + parameters: [{ $ref: '#/components/parameters/WorkspaceID' }] + get: + tags: [Repositories] + operationId: listRepositories + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/RepositoryPage' } + post: + tags: [Repositories] + operationId: createRepository + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/RepositoryInput' } + responses: + '202': + description: Repository provision accepted + content: + application/json: + schema: + type: object + required: [repository, operation] + properties: + repository: { $ref: '#/components/schemas/Repository' } + operation: { $ref: '#/components/schemas/RepositoryOperation' } + '400': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/repositories/{repositoryID}: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RepositoryID' } + get: + tags: [Repositories] + operationId: getRepository + responses: + '200': { $ref: '#/components/responses/Repository' } + '404': { $ref: '#/components/responses/Error' } + patch: + tags: [Repositories] + operationId: updateRepository + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + remoteUrl: { type: string, format: uri } + ref: { type: string } + providerConnectionId: { $ref: '#/components/schemas/UUID' } + responses: + '200': + description: Repository local fields updated synchronously + content: + application/json: + schema: + type: object + required: [repository] + properties: + repository: { $ref: '#/components/schemas/Repository' } + operation: + nullable: true + allOf: + - { $ref: '#/components/schemas/RepositoryOperation' } + '202': + description: Repository source update accepted + content: + application/json: + schema: + type: object + required: [repository, operation] + properties: + repository: { $ref: '#/components/schemas/Repository' } + operation: { $ref: '#/components/schemas/RepositoryOperation' } + delete: + tags: [Repositories] + operationId: requestRepositoryDeletion + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '202': { $ref: '#/components/responses/RepositoryOperation' } + /api/v1/workspaces/{workspaceID}/repositories/{repositoryID}/refresh: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RepositoryID' } + post: + tags: [Repositories] + operationId: refreshRepository + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + providerConnectionId: { $ref: '#/components/schemas/UUID' } + responses: + '202': { $ref: '#/components/responses/RepositoryOperation' } + /api/v1/workspaces/{workspaceID}/repositories/{repositoryID}/archive: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RepositoryID' } + post: + tags: [Repositories] + operationId: archiveRepository + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '200': { $ref: '#/components/responses/Repository' } + /api/v1/workspaces/{workspaceID}/repositories/{repositoryID}/restore: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RepositoryID' } + post: + tags: [Repositories] + operationId: restoreRepository + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '200': { $ref: '#/components/responses/Repository' } + /api/v1/workspaces/{workspaceID}/repositories/{repositoryID}/operations: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RepositoryID' } + get: + tags: [Repositories] + operationId: listRepositoryOperations + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/RepositoryOperationPage' } + /api/v1/workspaces/{workspaceID}/repositories/{repositoryID}/snapshots: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RepositoryID' } + get: + tags: [Repositories] + operationId: listRepositorySnapshots + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/SnapshotPage' } + /api/v1/workspaces/{workspaceID}/repositories/{repositoryID}/operations/{operationID}/cancel: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RepositoryID' } + - { $ref: '#/components/parameters/OperationID' } + post: + tags: [Repositories] + operationId: cancelRepositoryOperation + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '204': { $ref: '#/components/responses/NoContent' } + '409': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/repositories/{repositoryID}/analysis-runs: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RepositoryID' } + get: + tags: [Analysis] + operationId: listAnalysisRuns + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/AnalysisRunPage' } + post: + tags: [Analysis] + operationId: createAnalysisRun + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [profileId] + properties: + snapshotId: { $ref: '#/components/schemas/UUID' } + profileId: { $ref: '#/components/schemas/UUID' } + responses: + '202': { $ref: '#/components/responses/AnalysisRun' } + '409': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/analysis-profiles: + parameters: [{ $ref: '#/components/parameters/WorkspaceID' }] + get: + tags: [Analysis] + operationId: listAnalysisProfiles + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/AnalysisProfilePage' } + post: + tags: [Analysis] + operationId: createAnalysisProfile + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, definition] + properties: + name: { type: string, minLength: 1 } + definition: { $ref: '#/components/schemas/AnalysisProfileDefinition' } + responses: + '201': { $ref: '#/components/responses/AnalysisProfile' } + '400': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/analysis-profiles/{profileID}/versions: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/ProfileID' } + post: + tags: [Analysis] + operationId: createAnalysisProfileVersion + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [currentVersion, name, definition] + properties: + currentVersion: { type: integer, format: int32, minimum: 1 } + name: { type: string, minLength: 1 } + definition: { $ref: '#/components/schemas/AnalysisProfileDefinition' } + responses: + '201': { $ref: '#/components/responses/AnalysisProfile' } + '409': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/analysis-profiles/{profileID}: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/ProfileID' } + delete: + tags: [Analysis] + operationId: archiveAnalysisProfile + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '204': { $ref: '#/components/responses/NoContent' } + /api/v1/workspaces/{workspaceID}/analysis-runs/{runID}: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RunID' } + get: + tags: [Analysis] + operationId: getAnalysisRun + responses: + '200': { $ref: '#/components/responses/AnalysisRun' } + '404': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/analysis-runs/{runID}/cancel: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RunID' } + post: + tags: [Analysis] + operationId: cancelAnalysisRun + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '204': { $ref: '#/components/responses/NoContent' } + '409': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/analysis-runs/{runID}/retry: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/RunID' } + post: + tags: [Analysis] + operationId: retryAnalysisRun + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '202': { $ref: '#/components/responses/AnalysisRun' } + '409': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/reports/{reportID}: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/ReportID' } + get: + tags: [Analysis] + operationId: getAnalysisReport + responses: + '200': { $ref: '#/components/responses/AnalysisReport' } + '404': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/overview: + parameters: [{ $ref: '#/components/parameters/WorkspaceID' }] + get: + tags: [Channels] + operationId: workspaceOverview + responses: + '200': { $ref: '#/components/responses/Overview' } + /api/v1/workspaces/{workspaceID}/channels: + parameters: [{ $ref: '#/components/parameters/WorkspaceID' }] + get: + tags: [Channels] + operationId: listChannels + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/ChannelPage' } + post: + tags: [Channels] + operationId: createChannel + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ChannelInput' } + responses: + '201': { $ref: '#/components/responses/Channel' } + '400': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/channels/{channelID}: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/ChannelID' } + patch: + tags: [Channels] + operationId: updateChannel + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ChannelUpdate' } + responses: + '200': { $ref: '#/components/responses/Channel' } + '409': { $ref: '#/components/responses/Error' } + delete: + tags: [Channels] + operationId: deleteChannel + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '204': { $ref: '#/components/responses/NoContent' } + /api/v1/workspaces/{workspaceID}/channels/{channelID}/credentials: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/ChannelID' } + put: + tags: [Channels] + operationId: rotateChannelCredential + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [version, secrets] + properties: + version: { type: integer, format: int64, minimum: 1 } + secrets: { type: object, writeOnly: true, additionalProperties: { type: string } } + responses: + '200': { $ref: '#/components/responses/Channel' } + '409': { $ref: '#/components/responses/Error' } + /api/v1/workspaces/{workspaceID}/channels/{channelID}/enable: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/ChannelID' } + post: + tags: [Channels] + operationId: enableChannel + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '200': { $ref: '#/components/responses/Channel' } + /api/v1/workspaces/{workspaceID}/channels/{channelID}/disable: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/ChannelID' } + post: + tags: [Channels] + operationId: disableChannel + parameters: [{ $ref: '#/components/parameters/CSRF' }] + responses: + '200': { $ref: '#/components/responses/Channel' } + /api/v1/workspaces/{workspaceID}/channels/{channelID}/status: + parameters: + - { $ref: '#/components/parameters/WorkspaceID' } + - { $ref: '#/components/parameters/ChannelID' } + get: + tags: [Channels] + operationId: channelStatus + responses: + '200': { $ref: '#/components/responses/ChannelStatus' } + /api/v1/workspaces/{workspaceID}/conversations: + parameters: [{ $ref: '#/components/parameters/WorkspaceID' }] + get: + tags: [Channels] + operationId: listConversations + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + responses: + '200': { $ref: '#/components/responses/ConversationPage' } + /api/v1/workspaces/{workspaceID}/messages: + parameters: [{ $ref: '#/components/parameters/WorkspaceID' }] + get: + tags: [Channels] + operationId: listMessages + parameters: + - { $ref: '#/components/parameters/Cursor' } + - { $ref: '#/components/parameters/Limit' } + - name: channelId + in: query + schema: { $ref: '#/components/schemas/UUID' } + - name: conversationId + in: query + schema: { $ref: '#/components/schemas/UUID' } + responses: + '200': { $ref: '#/components/responses/MessagePage' } + /api/v1/channel-identity-links/accept: + post: + tags: [Channels] + operationId: acceptChannelIdentityLink + parameters: [{ $ref: '#/components/parameters/CSRF' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string, writeOnly: true } + responses: + '204': { $ref: '#/components/responses/NoContent' } + '400': { $ref: '#/components/responses/Error' } +components: + securitySchemes: + tinyauthSession: + type: apiKey + in: cookie + name: tinyauth + description: Browser session issued by Tinyauth after GitHub OAuth. + parameters: + CSRF: + name: X-CSRF-Token + in: header + required: true + schema: { type: string } + Cursor: + name: cursor + in: query + schema: { type: string } + description: Opaque cursor returned as `nextCursor` by the previous page. + Limit: + name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + WorkspaceID: { name: workspaceID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + UserID: { name: userID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + InvitationID: { name: invitationID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + ConnectionID: { name: connectionID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + RepositoryID: { name: repositoryID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + OperationID: { name: operationID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + RunID: { name: runID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + ReportID: { name: reportID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + ChannelID: { name: channelID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + ProfileID: { name: profileID, in: path, required: true, schema: { $ref: '#/components/schemas/UUID' } } + responses: + Health: + description: Process is alive + content: { application/json: { schema: { type: object, properties: { status: { type: string, enum: [ok] } } } } } + Ready: + description: PostgreSQL is reachable and all embedded migrations are applied + content: { application/json: { schema: { type: object, properties: { status: { type: string, enum: [ready] } } } } } + NotReady: + description: Service is not ready + content: { application/json: { schema: { type: object, properties: { status: { type: string, enum: [not_ready] } } } } } + Error: + description: Stable public error + content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } } + NoContent: { description: Operation completed } + Session: { description: Current session, content: { application/json: { schema: { $ref: '#/components/schemas/Session' } } } } + Workspace: { description: Workspace, content: { application/json: { schema: { $ref: '#/components/schemas/Workspace' } } } } + Invitation: { description: Workspace invitation, content: { application/json: { schema: { $ref: '#/components/schemas/Invitation' } } } } + MemberPage: { description: Workspace member page, content: { application/json: { schema: { $ref: '#/components/schemas/MemberPage' } } } } + InvitationPage: { description: Invitation page, content: { application/json: { schema: { $ref: '#/components/schemas/InvitationPage' } } } } + ProviderConnection: { description: Provider connection without credential material, content: { application/json: { schema: { $ref: '#/components/schemas/ProviderConnection' } } } } + ProviderConnectionPage: { description: Provider connection page, content: { application/json: { schema: { $ref: '#/components/schemas/ProviderConnectionPage' } } } } + Repository: { description: Repository, content: { application/json: { schema: { $ref: '#/components/schemas/Repository' } } } } + RepositoryPage: { description: Repository page, content: { application/json: { schema: { $ref: '#/components/schemas/RepositoryPage' } } } } + RepositoryOperation: { description: Repository operation, content: { application/json: { schema: { $ref: '#/components/schemas/RepositoryOperation' } } } } + RepositoryOperationPage: { description: Repository operation page, content: { application/json: { schema: { $ref: '#/components/schemas/RepositoryOperationPage' } } } } + SnapshotPage: { description: Commit snapshot page, content: { application/json: { schema: { $ref: '#/components/schemas/SnapshotPage' } } } } + AnalysisRun: { description: Analysis run, content: { application/json: { schema: { $ref: '#/components/schemas/AnalysisRun' } } } } + AnalysisRunPage: { description: Analysis run page, content: { application/json: { schema: { $ref: '#/components/schemas/AnalysisRunPage' } } } } + AnalysisReport: { description: Analysis report, content: { application/json: { schema: { $ref: '#/components/schemas/AnalysisReport' } } } } + AnalysisProfile: { description: Current immutable analysis profile version, content: { application/json: { schema: { $ref: '#/components/schemas/AnalysisProfile' } } } } + AnalysisProfilePage: { description: Analysis profile page, content: { application/json: { schema: { $ref: '#/components/schemas/AnalysisProfilePage' } } } } + Overview: { description: Workspace overview, content: { application/json: { schema: { $ref: '#/components/schemas/Overview' } } } } + Channel: { description: Channel without credential material, content: { application/json: { schema: { $ref: '#/components/schemas/Channel' } } } } + ChannelPage: { description: Channel page, content: { application/json: { schema: { $ref: '#/components/schemas/ChannelPage' } } } } + ChannelStatus: { description: Channel runtime status, content: { application/json: { schema: { $ref: '#/components/schemas/ChannelStatus' } } } } + ConversationPage: { description: Conversation page, content: { application/json: { schema: { $ref: '#/components/schemas/ConversationPage' } } } } + MessagePage: { description: Message page, content: { application/json: { schema: { $ref: '#/components/schemas/MessagePage' } } } } + schemas: + UUID: { type: string, format: uuid } + Timestamp: { type: string, format: date-time } + ErrorEnvelope: + type: object + required: [error] + properties: + error: + type: object + required: [code, message, requestId] + properties: + code: { type: string } + message: { type: string } + requestId: { type: string } + User: + type: object + required: [id, displayName, username, email, status] + properties: + id: { $ref: '#/components/schemas/UUID' } + displayName: { type: string } + username: { type: string } + email: { type: string } + status: { type: string, enum: [pending, active, suspended, deleted] } + Workspace: + type: object + required: [id, name, slug, reportRetentionDays, createdBy, role] + additionalProperties: false + properties: + id: { $ref: '#/components/schemas/UUID' } + name: { type: string } + slug: { type: string } + reportRetentionDays: { type: integer, minimum: 1, maximum: 3650 } + createdBy: { $ref: '#/components/schemas/UUID' } + role: { type: string, enum: [member, admin, owner] } + Session: + type: object + required: [user, workspaces, auth, registration] + properties: + user: { $ref: '#/components/schemas/User' } + workspaces: { type: array, items: { $ref: '#/components/schemas/Workspace' } } + auth: { type: object, properties: { logoutUrl: { type: string } } } + registration: { type: object, additionalProperties: true } + Member: + type: object + required: [userId, username, email, displayName, role, createdAt] + properties: + userId: { $ref: '#/components/schemas/UUID' } + username: { type: string } + email: { type: string } + displayName: { type: string } + role: { type: string, enum: [member, admin, owner] } + createdAt: { $ref: '#/components/schemas/Timestamp' } + Invitation: + type: object + required: [id, workspaceId, email, expiresAt, createdAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + workspaceId: { $ref: '#/components/schemas/UUID' } + email: { type: string, format: email } + token: { type: string, writeOnly: true } + expiresAt: { $ref: '#/components/schemas/Timestamp' } + acceptedAt: { $ref: '#/components/schemas/Timestamp' } + revokedAt: { $ref: '#/components/schemas/Timestamp' } + createdAt: { $ref: '#/components/schemas/Timestamp' } + ProviderCredentialInput: + type: object + required: [providerType, token] + properties: + providerType: { type: string, enum: [github, gitlab] } + baseUrl: { type: string, format: uri } + token: { type: string, writeOnly: true, minLength: 1 } + ProviderConnection: + type: object + required: [id, providerType, baseUrl, scopes, isDefault, status, credentialVersion, createdAt, updatedAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + providerType: { type: string, enum: [github, gitlab] } + baseUrl: { type: string, format: uri } + providerAccountId: { type: string } + login: { type: string } + displayName: { type: string } + scopes: { type: array, items: { type: string } } + isDefault: { type: boolean } + status: { type: string, enum: [active, invalid, revoked] } + credentialVersion: { type: integer, format: int64 } + lastValidatedAt: { $ref: '#/components/schemas/Timestamp' } + lastUsedAt: { $ref: '#/components/schemas/Timestamp' } + lastErrorCode: { type: string } + createdAt: { $ref: '#/components/schemas/Timestamp' } + updatedAt: { $ref: '#/components/schemas/Timestamp' } + RepositoryInput: + type: object + required: [remoteUrl, ref] + properties: + name: { type: string } + remoteUrl: { type: string, format: uri } + ref: { type: string, minLength: 1 } + providerConnectionId: { $ref: '#/components/schemas/UUID' } + Repository: + type: object + required: [id, workspaceId, providerType, name, remoteUrl, ref, configVersion, status, mirrorSizeBytes, createdAt, updatedAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + workspaceId: { $ref: '#/components/schemas/UUID' } + providerType: { type: string, enum: [github, gitlab] } + name: { type: string } + remoteUrl: { type: string, format: uri } + normalizedUrl: { type: string } + ref: { type: string } + configVersion: { type: integer, format: int64 } + status: { type: string, enum: [provisioning, syncing, ready, failed, deleting, deleted] } + currentSnapshotId: { $ref: '#/components/schemas/UUID' } + currentSnapshot: { $ref: '#/components/schemas/Snapshot' } + mirrorSizeBytes: { type: integer, format: int64, minimum: 0 } + lastSyncAt: { $ref: '#/components/schemas/Timestamp' } + lastErrorCode: { type: string } + lastErrorMessage: { type: string } + archivedAt: { $ref: '#/components/schemas/Timestamp' } + deletedAt: { $ref: '#/components/schemas/Timestamp' } + createdAt: { $ref: '#/components/schemas/Timestamp' } + updatedAt: { $ref: '#/components/schemas/Timestamp' } + RepositoryOperation: + type: object + required: [id, repositoryId, actorUserId, repositoryVersion, kind, requestedProviderType, requestedRemoteUrl, requestedNormalizedUrl, requestedRef, status, createdAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + repositoryId: { $ref: '#/components/schemas/UUID' } + actorUserId: { $ref: '#/components/schemas/UUID' } + providerConnectionId: { $ref: '#/components/schemas/UUID' } + credentialVersion: { type: integer, format: int64 } + repositoryVersion: { type: integer, format: int64 } + kind: { type: string, enum: [provision, refresh, update, purge] } + requestedProviderType: { type: string, enum: [github, gitlab] } + requestedRemoteUrl: { type: string, format: uri } + requestedNormalizedUrl: { type: string } + requestedRef: { type: string } + status: { type: string, enum: [queued, running, succeeded, failed, cancelled] } + outcome: { type: string, enum: [changed, no_change] } + resolvedCommitSha: { type: string } + snapshotId: { $ref: '#/components/schemas/UUID' } + workflowRunId: { type: string } + errorMessage: { type: string } + createdAt: { $ref: '#/components/schemas/Timestamp' } + startedAt: { $ref: '#/components/schemas/Timestamp' } + finishedAt: { $ref: '#/components/schemas/Timestamp' } + Snapshot: + type: object + required: [id, repositoryId, commitSha, sourceRef, sourceState, createdAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + repositoryId: { $ref: '#/components/schemas/UUID' } + commitSha: { type: string } + sourceRef: { type: string } + authorName: { type: string } + authoredAt: { $ref: '#/components/schemas/Timestamp' } + title: { type: string } + sourceState: { type: string, enum: [available, purging, purged] } + createdAt: { $ref: '#/components/schemas/Timestamp' } + AnalysisRun: + type: object + required: [id, workspaceId, repositoryId, snapshotId, commitSha, requestedBy, dimensionKey, profileId, profileVersion, profileSnapshot, analyzerVersion, attempt, status, stage, retryable, createdAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + workspaceId: { $ref: '#/components/schemas/UUID' } + repositoryId: { $ref: '#/components/schemas/UUID' } + snapshotId: { $ref: '#/components/schemas/UUID' } + commitSha: { type: string } + requestedBy: { $ref: '#/components/schemas/UUID' } + dimensionKey: { type: string, enum: [code_scale] } + profileId: { $ref: '#/components/schemas/UUID' } + profileVersion: { type: string } + profileSnapshot: { $ref: '#/components/schemas/AnalysisProfileDefinition' } + analyzerVersion: { type: string } + attempt: { type: integer, format: int32, minimum: 1 } + rerunOf: { $ref: '#/components/schemas/UUID' } + status: { type: string, enum: [queued, running, succeeded, failed, cancelled] } + stage: { type: string, enum: [queued, prepare, authorize, checkout, analyze, persist, workflow, complete, cancelled] } + reportId: { $ref: '#/components/schemas/UUID' } + report: { $ref: '#/components/schemas/AnalysisReport' } + workflowRunId: { type: string } + failedStage: { type: string, enum: [prepare, authorize, checkout, analyze, persist, workflow] } + errorCode: { type: string } + errorMessage: { type: string } + retryable: { type: boolean } + createdAt: { $ref: '#/components/schemas/Timestamp' } + startedAt: { $ref: '#/components/schemas/Timestamp' } + finishedAt: { $ref: '#/components/schemas/Timestamp' } + AnalysisReport: + type: object + required: [id, analysisRunId, workspaceId, repositoryId, snapshotId, commitSha, sourceRef, dimensionKey, profileId, profileVersion, profileSnapshot, analyzerVersion, executionEnvironment, startedAt, finishedAt, durationMs, result, createdAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + analysisRunId: { $ref: '#/components/schemas/UUID' } + workspaceId: { $ref: '#/components/schemas/UUID' } + repositoryId: { $ref: '#/components/schemas/UUID' } + snapshotId: { $ref: '#/components/schemas/UUID' } + commitSha: { type: string } + sourceRef: { type: string } + commitAuthor: { type: string } + commitAuthoredAt: { $ref: '#/components/schemas/Timestamp' } + commitTitle: { type: string } + dimensionKey: { type: string } + profileId: { $ref: '#/components/schemas/UUID' } + profileVersion: { type: string } + profileSnapshot: { $ref: '#/components/schemas/AnalysisProfileDefinition' } + analyzerVersion: { type: string } + executionEnvironment: { type: string } + startedAt: { $ref: '#/components/schemas/Timestamp' } + finishedAt: { $ref: '#/components/schemas/Timestamp' } + durationMs: { type: integer, format: int64, minimum: 0 } + result: { $ref: '#/components/schemas/CodeScaleResult' } + createdAt: { $ref: '#/components/schemas/Timestamp' } + CodeScaleSummary: + type: object + additionalProperties: false + required: [files, lines, code, comments, blanks, bytes, complexity] + properties: + files: { type: integer, format: int64, minimum: 0 } + lines: { type: integer, format: int64, minimum: 0 } + code: { type: integer, format: int64, minimum: 0 } + comments: { type: integer, format: int64, minimum: 0 } + blanks: { type: integer, format: int64, minimum: 0 } + bytes: { type: integer, format: int64, minimum: 0 } + complexity: { type: integer, format: int64, minimum: 0 } + CodeScaleLanguage: + type: object + additionalProperties: false + required: [name, files, lines, code, comments, blanks, bytes, complexity] + properties: + name: { type: string, minLength: 1 } + files: { type: integer, format: int64, minimum: 0 } + lines: { type: integer, format: int64, minimum: 0 } + code: { type: integer, format: int64, minimum: 0 } + comments: { type: integer, format: int64, minimum: 0 } + blanks: { type: integer, format: int64, minimum: 0 } + bytes: { type: integer, format: int64, minimum: 0 } + complexity: { type: integer, format: int64, minimum: 0 } + CodeScaleResult: + type: object + additionalProperties: false + required: [summary, languages, warnings] + properties: + summary: { $ref: '#/components/schemas/CodeScaleSummary' } + languages: + type: array + items: { $ref: '#/components/schemas/CodeScaleLanguage' } + warnings: + type: array + items: { type: string } + AnalysisProfileDefinition: + type: object + additionalProperties: false + required: [dimensionKey, timeoutSeconds, analyzer] + properties: + dimensionKey: { type: string, enum: [code_scale] } + timeoutSeconds: { type: integer, format: int64, minimum: 1, maximum: 86400 } + analyzer: + type: object + additionalProperties: false + required: [key, required] + properties: + key: { type: string, enum: [scc] } + required: { type: boolean, enum: [true] } + AnalysisProfile: + type: object + required: [id, workspaceId, name, currentVersion, dimensionKey, definition, createdBy, createdAt, updatedAt, versionCreatedAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + workspaceId: { $ref: '#/components/schemas/UUID' } + name: { type: string } + currentVersion: { type: integer, format: int32, minimum: 1 } + dimensionKey: { type: string, enum: [code_scale] } + definition: { $ref: '#/components/schemas/AnalysisProfileDefinition' } + createdBy: { $ref: '#/components/schemas/UUID' } + createdAt: { $ref: '#/components/schemas/Timestamp' } + updatedAt: { $ref: '#/components/schemas/Timestamp' } + versionCreatedAt: { $ref: '#/components/schemas/Timestamp' } + ChannelInput: + type: object + required: [type, name, values, secrets, senderAllowList, groupPolicy, notificationEvents] + properties: + type: { type: string, enum: [feishu, dingtalk] } + name: { type: string, minLength: 1 } + values: { type: object, additionalProperties: true } + secrets: { type: object, writeOnly: true, additionalProperties: { type: string } } + senderAllowList: { type: array, items: { type: string } } + groupPolicy: { type: object, additionalProperties: true } + notificationEvents: + type: array + uniqueItems: true + items: { type: string, enum: [analysis.started, analysis.succeeded, analysis.failed] } + ChannelUpdate: + type: object + required: [version, name, values, senderAllowList, groupPolicy, notificationEvents] + properties: + version: { type: integer, format: int64, minimum: 1 } + name: { type: string, minLength: 1 } + values: { type: object, additionalProperties: true } + senderAllowList: { type: array, items: { type: string } } + groupPolicy: { type: object, additionalProperties: true } + notificationEvents: + type: array + uniqueItems: true + items: { type: string, enum: [analysis.started, analysis.succeeded, analysis.failed] } + Channel: + type: object + required: [id, type, name, enabled, secretConfigured, configVersion, config, notificationEvents, createdAt, updatedAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + type: { type: string, enum: [feishu, dingtalk] } + name: { type: string } + enabled: { type: boolean } + secretConfigured: { type: boolean } + configVersion: { type: integer, format: int64 } + config: { type: object, additionalProperties: true } + notificationEvents: + type: array + uniqueItems: true + items: { type: string, enum: [analysis.started, analysis.succeeded, analysis.failed] } + createdAt: { $ref: '#/components/schemas/Timestamp' } + updatedAt: { $ref: '#/components/schemas/Timestamp' } + ChannelStatus: + type: object + required: [state] + properties: + state: { type: string, enum: [disabled, starting, connected, error] } + lastConnectedAt: { $ref: '#/components/schemas/Timestamp' } + lastErrorMessage: { type: string } + Conversation: + type: object + required: [id, channelInstanceId, channelName, channelType, externalId, type, title, createdAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + channelInstanceId: { $ref: '#/components/schemas/UUID' } + channelName: { type: string } + channelType: { type: string } + externalId: { type: string } + type: { type: string } + title: { type: string } + createdAt: { $ref: '#/components/schemas/Timestamp' } + Message: + type: object + required: [id, channelInstanceId, channelName, channelType, conversationId, senderCanonicalId, content, occurredAt] + properties: + id: { $ref: '#/components/schemas/UUID' } + channelInstanceId: { $ref: '#/components/schemas/UUID' } + channelName: { type: string } + channelType: { type: string } + conversationId: { $ref: '#/components/schemas/UUID' } + conversationExternalId: { type: string } + senderCanonicalId: { type: string } + senderDisplayName: { type: string } + conversationType: { type: string } + content: { type: object, additionalProperties: true } + occurredAt: { $ref: '#/components/schemas/Timestamp' } + Overview: + type: object + required: [repositoryCount, repositoryWithoutCodeCount, recentFailedSyncCount, activeAnalysisCount, activeChannelCount, recentMessages] + properties: + repositoryCount: { type: integer, format: int64 } + repositoryWithoutCodeCount: { type: integer, format: int64 } + recentFailedSyncCount: { type: integer, format: int64 } + activeAnalysisCount: { type: integer, format: int64 } + activeChannelCount: { type: integer, format: int64 } + recentMessages: { type: array, items: { $ref: '#/components/schemas/Message' } } + MemberPage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Member' } } } }] } + InvitationPage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Invitation' } } } }] } + ProviderConnectionPage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/ProviderConnection' } } } }] } + RepositoryPage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Repository' } } } }] } + RepositoryOperationPage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/RepositoryOperation' } } } }] } + SnapshotPage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Snapshot' } } } }] } + AnalysisRunPage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/AnalysisRun' } } } }] } + AnalysisProfilePage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/AnalysisProfile' } } } }] } + ChannelPage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Channel' } } } }] } + ConversationPage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Conversation' } } } }] } + MessagePage: { allOf: [{ $ref: '#/components/schemas/PageBase' }, { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Message' } } } }] } + PageBase: + type: object + required: [items] + properties: + nextCursor: { type: string } diff --git a/api/openapi/spec.go b/api/openapi/spec.go new file mode 100644 index 0000000..5b87b38 --- /dev/null +++ b/api/openapi/spec.go @@ -0,0 +1,10 @@ +package openapi + +import "embed" + +//go:embed mooncode.yaml +var files embed.FS + +func Document() ([]byte, error) { + return files.ReadFile("mooncode.yaml") +} diff --git a/api/openapi/spec_test.go b/api/openapi/spec_test.go new file mode 100644 index 0000000..ab165f9 --- /dev/null +++ b/api/openapi/spec_test.go @@ -0,0 +1,80 @@ +package openapi + +import ( + "context" + "slices" + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestDocumentIsValidOpenAPI(t *testing.T) { + document, err := Document() + if err != nil { + t.Fatal(err) + } + specification, err := openapi3.NewLoader().LoadFromData(document) + if err != nil { + t.Fatal(err) + } + if err := specification.Validate(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestAnalysisReportUsesClosedCodeScaleResult(t *testing.T) { + document, err := Document() + if err != nil { + t.Fatal(err) + } + specification, err := openapi3.NewLoader().LoadFromData(document) + if err != nil { + t.Fatal(err) + } + report := specification.Components.Schemas["AnalysisReport"].Value + resultProperty := report.Properties["result"] + if resultProperty.Ref != "#/components/schemas/CodeScaleResult" { + t.Fatalf("AnalysisReport.result ref = %q", resultProperty.Ref) + } + result := specification.Components.Schemas["CodeScaleResult"].Value + if result.AdditionalProperties.Has == nil || *result.AdditionalProperties.Has { + t.Fatal("CodeScaleResult must reject undeclared properties") + } + for _, required := range []string{"summary", "languages", "warnings"} { + if !slices.Contains(result.Required, required) { + t.Fatalf("CodeScaleResult does not require %q", required) + } + } +} + +func TestWorkspaceContractIncludesBoundedRetentionPolicy(t *testing.T) { + document, err := Document() + if err != nil { + t.Fatal(err) + } + specification, err := openapi3.NewLoader().LoadFromData(document) + if err != nil { + t.Fatal(err) + } + + workspace := specification.Components.Schemas["Workspace"].Value + if workspace.AdditionalProperties.Has == nil || *workspace.AdditionalProperties.Has { + t.Fatal("Workspace must reject undeclared properties") + } + if !slices.Contains(workspace.Required, "reportRetentionDays") { + t.Fatal("Workspace does not require reportRetentionDays") + } + retention := workspace.Properties["reportRetentionDays"].Value + if retention.Min == nil || *retention.Min != 1 || retention.Max == nil || *retention.Max != 3650 { + t.Fatalf("reportRetentionDays bounds = (%v, %v)", retention.Min, retention.Max) + } + + update := specification.Paths.Find("/api/v1/workspaces/{workspaceID}").Patch + if update == nil || update.OperationID != "updateWorkspace" { + t.Fatal("workspace PATCH operation is missing") + } + input := update.RequestBody.Value.Content.Get("application/json").Schema.Value + if _, ok := input.Properties["reportRetentionDays"]; !ok { + t.Fatal("workspace PATCH does not accept reportRetentionDays") + } +} diff --git a/cmd/mooncode/main.go b/cmd/mooncode/main.go index 80aec53..32db099 100644 --- a/cmd/mooncode/main.go +++ b/cmd/mooncode/main.go @@ -2,14 +2,16 @@ package main import ( "fmt" + "log/slog" "os" - "github.com/mooncode-ai/mooncode/internal/cli" - "github.com/mooncode-ai/mooncode/internal/version" + "github.com/fuchencong/mooncode/internal/cli" ) func main() { - if err := cli.NewRootCommand(version.Current()).Execute(); err != nil { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + + if err := cli.NewRoot().Execute(); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/deploy/backend.Dockerfile b/deploy/backend.Dockerfile index 7490ea8..effea47 100644 --- a/deploy/backend.Dockerfile +++ b/deploy/backend.Dockerfile @@ -1,41 +1,44 @@ -FROM golang:1.25-bookworm AS build +FROM golang:1.26-bookworm AS build +ARG GOPROXY=https://goproxy.cn,direct ARG VERSION=dev -ARG COMMIT=none -ARG BUILD_DATE=unknown +ARG COMMIT=unknown +ARG BUILD_TIME=unknown +ENV GOPROXY=${GOPROXY} WORKDIR /src - COPY go.mod go.sum ./ RUN go mod download +COPY api ./api COPY cmd ./cmd COPY internal ./internal -COPY pkg ./pkg COPY migrations ./migrations +COPY pkg ./pkg -RUN CGO_ENABLED=0 GOOS=linux go build \ - -trimpath \ - -buildvcs=false \ - -ldflags="-s -w \ - -X github.com/mooncode-ai/mooncode/internal/version.Version=${VERSION} \ - -X github.com/mooncode-ai/mooncode/internal/version.Commit=${COMMIT} \ - -X github.com/mooncode-ai/mooncode/internal/version.Date=${BUILD_DATE}" \ +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \ + -ldflags="-s -w -X github.com/fuchencong/mooncode/internal/platform/buildinfo.Version=${VERSION} -X github.com/fuchencong/mooncode/internal/platform/buildinfo.Commit=${COMMIT} -X github.com/fuchencong/mooncode/internal/platform/buildinfo.BuildTime=${BUILD_TIME}" \ -o /out/mooncode ./cmd/mooncode +RUN mkdir /tmp/scc \ + && cd /tmp/scc \ + && go mod init mooncode.local/tools/scc \ + && go get github.com/boyter/scc/v3@v3.7.0 \ + golang.org/x/crypto@v0.54.0 \ + golang.org/x/text@v0.40.0 \ + && CGO_ENABLED=0 go build -trimpath -o /out/scc github.com/boyter/scc/v3 + FROM debian:bookworm-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git \ - && rm -rf /var/lib/apt/lists/* + && apt-get install -y --no-install-recommends ca-certificates curl git \ + && rm -rf /var/lib/apt/lists/* \ + && install -d -o 65532 -g 65532 /var/lib/mooncode/repositories COPY --from=build /out/mooncode /usr/local/bin/mooncode - -RUN install -d -o 65532 -g 65532 /var/lib/mooncode/repositories +COPY --from=build /out/scc /usr/local/bin/scc USER 65532:65532 - EXPOSE 8080 9090 - ENTRYPOINT ["/usr/local/bin/mooncode"] CMD ["serve"] diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 0194a17..3fb607f 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -1,39 +1,20 @@ services: - mooncode-backend: - environment: - MOONCODE_CHANNELS_FAKE_ENABLED: "true" - # Development has no pre-existing owner who can issue the first invite. - MOONCODE_REGISTRATION_MODE: "public" + mooncode-api: + build: + network: host + + mooncode-worker: + build: + network: host mooncode-frontend: build: target: development - args: - HTTP_PROXY: ${MOONCODE_BACKEND_HTTP_PROXY:-} - HTTPS_PROXY: ${MOONCODE_BACKEND_HTTPS_PROXY:-${MOONCODE_BACKEND_HTTP_PROXY:-}} - NO_PROXY: localhost,127.0.0.1,::1 command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] environment: NODE_ENV: development volumes: - ../frontend:/src - # Keep Linux dependencies inside the container while the source tree is - # bind-mounted. Renew this volume only when package-lock.json changes. - /src/node_modules labels: - traefik.enable: "true" - traefik.docker.network: mooncode_edge - traefik.http.routers.mooncode-frontend.entrypoints: web - traefik.http.routers.mooncode-frontend.rule: Host(`mooncode.localhost`) && PathPrefix(`/`) - traefik.http.routers.mooncode-frontend.priority: "1" - traefik.http.services.mooncode-frontend.loadbalancer.server.port: "5173" - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:5173').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) - interval: 3s - timeout: 3s - retries: 20 - start_period: 3s + - traefik.http.services.mooncode-frontend.loadbalancer.server.port=5173 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 23f92cb..ad01f87 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,197 +1,224 @@ name: mooncode +x-mooncode-environment: &mooncode-environment + GIN_MODE: release + MOONCODE_DATABASE_URL: postgres://mooncode:mooncode@postgres:5432/mooncode?sslmode=disable + MOONCODE_DATABASE_MIGRATE_ON_START: "true" + MOONCODE_AUTH_ISSUER: tinyauth + MOONCODE_AUTH_APP_URL: http://mooncode.localhost:3100/app + MOONCODE_AUTH_LOGOUT_URL: /api/user/logout + MOONCODE_AUTH_REGISTRATION_MODE: ${MOONCODE_REGISTRATION_MODE:-public} + MOONCODE_AUTH_TERMS_VERSION: "2026-07-30" + MOONCODE_AUTH_PRIVACY_VERSION: "2026-07-30" + MOONCODE_AUTH_TRUSTED_PROXY_CIDRS: ${MOONCODE_AUTH_TRUSTED_PROXY_CIDRS:-172.30.0.2/32} + MOONCODE_AUTH_CSRF_KEY: ${MOONCODE_AUTH_CSRF_KEY:?MOONCODE_AUTH_CSRF_KEY is required} + MOONCODE_SECRETS_KEY: ${MOONCODE_SECRETS_KEY:?MOONCODE_SECRETS_KEY is required} + MOONCODE_SECRETS_KEY_VERSION: "1" + MOONCODE_REPOSITORY_ROOT: /var/lib/mooncode/repositories + MOONCODE_REPOSITORY_WORKTREE_MAX_AGE: 24h + MOONCODE_REPOSITORY_MAX_MIRROR_BYTES: ${MOONCODE_REPOSITORY_MAX_MIRROR_BYTES:-10737418240} + MOONCODE_REPOSITORY_MAX_PER_WORKSPACE: ${MOONCODE_REPOSITORY_MAX_PER_WORKSPACE:-100} + MOONCODE_HATCHET_TOKEN: ${HATCHET_CLIENT_TOKEN:-} + MOONCODE_HATCHET_ADDRESS: hatchet:7077 + MOONCODE_HATCHET_NAMESPACE: mooncode + HATCHET_CLIENT_SERVER_URL: http://hatchet:8888 + HATCHET_CLIENT_TLS_STRATEGY: none + MOONCODE_ANALYSIS_SCC_PATH: /usr/local/bin/scc + MOONCODE_ANALYSIS_TIMEOUT: 15m + MOONCODE_ANALYSIS_MAX_OUTPUT_BYTES: "16777216" + MOONCODE_ANALYSIS_MAX_CONCURRENT_PER_WORKSPACE: ${MOONCODE_ANALYSIS_MAX_CONCURRENT_PER_WORKSPACE:-10} + MOONCODE_METRICS_WORKER_ADDRESS: ${MOONCODE_METRICS_WORKER_ADDRESS:-:9090} + HTTP_PROXY: ${MOONCODE_BACKEND_HTTP_PROXY:-} + HTTPS_PROXY: ${MOONCODE_BACKEND_HTTP_PROXY:-} + NO_PROXY: localhost,127.0.0.1,::1,postgres,hatchet,mooncode-api,mooncode-worker + services: traefik: - container_name: mooncode-traefik image: traefik:v3.5 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --ping=true - - --api.dashboard=false - - --log.level=INFO - - --accesslog=true ports: - "3100:80" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ping"] + test: ["CMD", "traefik", "healthcheck", "--ping"] interval: 5s timeout: 3s - retries: 10 - start_period: 3s + retries: 20 restart: unless-stopped networks: - - edge + edge: + ipv4_address: 172.30.0.2 + + postgres: + image: postgres:17-bookworm + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + volumes: + - postgres-data:/var/lib/postgresql/data + - ./postgres-init.sql:/docker-entrypoint-initdb.d/010-databases.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 3s + timeout: 3s + retries: 30 + restart: unless-stopped + networks: [backend] - mooncode-backend: - container_name: mooncode-backend + hatchet: + image: ghcr.io/hatchet-dev/hatchet/hatchet-lite:v0.98.9 + environment: + DATABASE_URL: postgres://hatchet:hatchet@postgres:5432/hatchet?sslmode=disable + SERVER_GRPC_BIND_ADDRESS: 0.0.0.0 + SERVER_GRPC_PORT: "7077" + SERVER_GRPC_BROADCAST_ADDRESS: hatchet:7077 + SERVER_GRPC_INSECURE: "true" + SERVER_INTERNAL_CLIENT_INTERNAL_GRPC_BROADCAST_ADDRESS: hatchet:7077 + SERVER_DEFAULT_ENGINE_VERSION: V1 + SERVER_AUTH_COOKIE_INSECURE: "true" + SERVER_AUTH_COOKIE_DOMAIN: hatchet.mooncode.localhost + SERVER_URL: http://hatchet.mooncode.localhost:3100 + depends_on: + postgres: {condition: service_healthy} + labels: + - traefik.enable=true + - traefik.docker.network=mooncode_edge + - traefik.http.routers.hatchet.rule=Host(`hatchet.mooncode.localhost`) + - traefik.http.services.hatchet.loadbalancer.server.port=8888 + restart: unless-stopped + networks: [edge, backend] + + mooncode-api: build: context: .. dockerfile: deploy/backend.Dockerfile args: - VERSION: local - COMMIT: workspace - BUILD_DATE: local HTTP_PROXY: ${MOONCODE_BACKEND_HTTP_PROXY:-} - HTTPS_PROXY: ${MOONCODE_BACKEND_HTTPS_PROXY:-${MOONCODE_BACKEND_HTTP_PROXY:-}} + HTTPS_PROXY: ${MOONCODE_BACKEND_HTTP_PROXY:-} NO_PROXY: localhost,127.0.0.1,::1 + command: ["serve"] environment: - MOONCODE_SERVICE_ENVIRONMENT: development - MOONCODE_DATABASE_URL: postgres://mooncode:mooncode@postgres:5432/mooncode?sslmode=disable - MOONCODE_DATABASE_AUTO_MIGRATE: "true" - MOONCODE_IDENTITY_MODE: oauth_subject_required - MOONCODE_IDENTITY_ISSUER: https://github.com - MOONCODE_IDENTITY_AUTH_URL: http://auth.mooncode.localhost:3100 - MOONCODE_IDENTITY_GATEWAY_TOKEN: ${MOONCODE_GATEWAY_TOKEN:-mooncode-local-gateway-token-change-me} - MOONCODE_REGISTRATION_MODE: ${MOONCODE_REGISTRATION_MODE:-invite_only} - MOONCODE_REGISTRATION_TERMS_VERSION: ${MOONCODE_TERMS_VERSION:-2026-07-01} - MOONCODE_REGISTRATION_PRIVACY_VERSION: ${MOONCODE_PRIVACY_VERSION:-2026-07-01} - MOONCODE_REGISTRATION_INVITATION_TTL: ${MOONCODE_INVITATION_TTL:-168h} - MOONCODE_REGISTRATION_CREATE_PERSONAL_WORKSPACE: "true" - MOONCODE_SECRET_STORE_MASTER_KEY: ${MOONCODE_SECRET_STORE_MASTER_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=} - # Independent development key; production deployments must override it. - MOONCODE_SECURITY_CSRF_KEY: ${MOONCODE_CSRF_KEY:-AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=} - MOONCODE_GIT_REPOSITORY_DIRECTORY: /var/lib/mooncode/repositories - MOONCODE_SERVER_ADDRESS: ":8080" - # Keep operational endpoints inside the container. Because this one - # backend joins both edge and backend networks, binding :9090 would also - # make the admin listener reachable from edge peers. - MOONCODE_ADMIN_ADDRESS: "127.0.0.1:9090" - MOONCODE_LOG_FORMAT: json - MOONCODE_LOG_LEVEL: info - # Git and IM SDKs use the backend-specific egress proxy. Docker's - # host-wide container proxy injection stays disabled. - HTTP_PROXY: ${MOONCODE_BACKEND_HTTP_PROXY:-} - HTTPS_PROXY: ${MOONCODE_BACKEND_HTTPS_PROXY:-${MOONCODE_BACKEND_HTTP_PROXY:-}} - NO_PROXY: localhost,127.0.0.1,::1,postgres,mooncode-backend + <<: *mooncode-environment + MOONCODE_HTTP_ADDRESS: ":8080" depends_on: - postgres: - condition: service_healthy + postgres: {condition: service_healthy} + hatchet: {condition: service_started} + volumes: + - repository-data:/var/lib/mooncode/repositories labels: - traefik.enable=true - traefik.docker.network=mooncode_edge - - traefik.http.routers.mooncode-api.entrypoints=web - - traefik.http.routers.mooncode-api.rule=Host(`mooncode.localhost`) && PathPrefix(`/api/`) + - traefik.http.routers.mooncode-api.rule=Host(`mooncode.localhost`) && (PathPrefix(`/api/v1/`) || Path(`/api/openapi.yaml`)) - traefik.http.routers.mooncode-api.priority=100 - - traefik.http.routers.mooncode-api.middlewares=mooncode-auth,mooncode-gateway + - traefik.http.routers.mooncode-api.middlewares=mooncode-strip-identity,mooncode-forward-auth - traefik.http.services.mooncode-api.loadbalancer.server.port=8080 - - traefik.http.middlewares.mooncode-auth.forwardauth.address=http://tinyauth:3000/api/auth/traefik - - traefik.http.middlewares.mooncode-auth.forwardauth.authrequestheaders=Cookie - - traefik.http.middlewares.mooncode-auth.forwardauth.authresponseheaders=Remote-User,Remote-Name,Remote-Email,Remote-Groups,Remote-Sub - - traefik.http.middlewares.mooncode-gateway.headers.customrequestheaders.X-MoonCode-Gateway-Token=${MOONCODE_GATEWAY_TOKEN:-mooncode-local-gateway-token-change-me} + # Strip every client-supplied identity header before ForwardAuth. + - traefik.http.middlewares.mooncode-strip-identity.headers.customrequestheaders.Remote-Sub= + - traefik.http.middlewares.mooncode-strip-identity.headers.customrequestheaders.Remote-User= + - traefik.http.middlewares.mooncode-strip-identity.headers.customrequestheaders.Remote-Name= + - traefik.http.middlewares.mooncode-strip-identity.headers.customrequestheaders.Remote-Email= + - traefik.http.middlewares.mooncode-strip-identity.headers.customrequestheaders.Remote-Groups= + - traefik.http.middlewares.mooncode-forward-auth.forwardauth.address=http://tinyauth:3000/api/auth/traefik + - traefik.http.middlewares.mooncode-forward-auth.forwardauth.authrequestheaders=Cookie + - traefik.http.middlewares.mooncode-forward-auth.forwardauth.authresponseheaders=Remote-Sub,Remote-User,Remote-Name,Remote-Email,Remote-Groups healthcheck: - test: ["CMD", "/usr/local/bin/mooncode", "healthcheck", "--url", "http://127.0.0.1:9090/readyz"] + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/readyz"] interval: 5s timeout: 3s - retries: 10 - start_period: 3s + retries: 30 read_only: true - tmpfs: - - /tmp:size=64m,mode=1777 - volumes: - - repository-data:/var/lib/mooncode/repositories - cap_drop: - - ALL - security_opt: - - no-new-privileges:true + tmpfs: [/tmp] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] restart: unless-stopped - networks: - - edge - - backend + networks: [edge, backend] - postgres: - image: postgres:17-bookworm - environment: - POSTGRES_DB: mooncode - POSTGRES_USER: mooncode - POSTGRES_PASSWORD: mooncode + mooncode-worker: + build: + context: .. + dockerfile: deploy/backend.Dockerfile + args: + HTTP_PROXY: ${MOONCODE_BACKEND_HTTP_PROXY:-} + HTTPS_PROXY: ${MOONCODE_BACKEND_HTTP_PROXY:-} + NO_PROXY: localhost,127.0.0.1,::1 + command: ["worker"] + environment: *mooncode-environment + depends_on: + mooncode-api: {condition: service_healthy} + volumes: + - repository-data:/var/lib/mooncode/repositories + expose: ["9090"] healthcheck: - test: ["CMD-SHELL", "pg_isready -U mooncode -d mooncode"] - interval: 3s + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9090/healthz"] + interval: 5s timeout: 3s - retries: 20 - start_period: 5s - volumes: - - postgres-data:/var/lib/postgresql/data + retries: 30 + read_only: true + tmpfs: [/tmp] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] restart: unless-stopped - networks: - - backend + networks: [backend, outbound] tinyauth: image: ghcr.io/tinyauthapp/tinyauth:v5.1.2 environment: - TINYAUTH_APPURL: http://auth.mooncode.localhost:3100 + TINYAUTH_APPURL: http://mooncode.localhost:3100 TINYAUTH_AUTH_SECURECOOKIE: "false" - TINYAUTH_AUTH_SUBDOMAINSENABLED: "true" - TINYAUTH_AUTH_SESSIONEXPIRY: "28800" - TINYAUTH_AUTH_SESSIONMAXLIFETIME: "604800" + TINYAUTH_AUTH_SUBDOMAINSENABLED: "false" TINYAUTH_AUTH_ACLS_POLICY: allow TINYAUTH_OAUTH_AUTOREDIRECT: github TINYAUTH_OAUTH_PROVIDERS_GITHUB_CLIENTID: ${GITHUB_OAUTH_CLIENT_ID:?GITHUB_OAUTH_CLIENT_ID is required} TINYAUTH_OAUTH_PROVIDERS_GITHUB_CLIENTSECRETFILE: /run/secrets/github_oauth_client_secret - TINYAUTH_OAUTH_PROVIDERS_GITHUB_REDIRECTURL: http://auth.mooncode.localhost:3100/api/oauth/callback/github - TINYAUTH_UI_TITLE: MoonCode Login - # OAuth callbacks exchange the authorization code with GitHub. Keep this - # proxy independent from the backend proxy so egress stays service-scoped. + TINYAUTH_OAUTH_PROVIDERS_GITHUB_REDIRECTURL: http://mooncode.localhost:3100/api/oauth/callback/github HTTP_PROXY: ${MOONCODE_TINYAUTH_HTTP_PROXY:-} - HTTPS_PROXY: ${MOONCODE_TINYAUTH_HTTPS_PROXY:-${MOONCODE_TINYAUTH_HTTP_PROXY:-}} - NO_PROXY: localhost,127.0.0.1,::1,tinyauth,mooncode-backend - secrets: - - github_oauth_client_secret + HTTPS_PROXY: ${MOONCODE_TINYAUTH_HTTP_PROXY:-} + NO_PROXY: localhost,127.0.0.1,::1,tinyauth,mooncode-api + secrets: [github_oauth_client_secret] + volumes: [tinyauth-data:/data] labels: - traefik.enable=true - traefik.docker.network=mooncode_edge - - traefik.http.routers.tinyauth.entrypoints=web - - traefik.http.routers.tinyauth.rule=Host(`auth.mooncode.localhost`) + - traefik.http.routers.tinyauth.rule=Host(`mooncode.localhost`) && (PathPrefix(`/api/oauth/`) || Path(`/api/user/logout`)) + - traefik.http.routers.tinyauth.priority=200 - traefik.http.services.tinyauth.loadbalancer.server.port=3000 - - traefik.http.routers.tinyauth-oauth-start.entrypoints=web - - traefik.http.routers.tinyauth-oauth-start.rule=Host(`auth.mooncode.localhost`) && PathPrefix(`/api/oauth/url/`) - - traefik.http.routers.tinyauth-oauth-start.priority=100 - - traefik.http.routers.tinyauth-oauth-start.middlewares=tinyauth-oauth-limit - - traefik.http.routers.tinyauth-oauth-start.service=tinyauth - - traefik.http.middlewares.tinyauth-oauth-limit.ratelimit.average=10 - - traefik.http.middlewares.tinyauth-oauth-limit.ratelimit.period=1m - - traefik.http.middlewares.tinyauth-oauth-limit.ratelimit.burst=5 - volumes: - - tinyauth-data:/data restart: unless-stopped - networks: - - edge + networks: [edge] mooncode-frontend: - container_name: mooncode-frontend build: context: .. dockerfile: deploy/frontend.Dockerfile depends_on: - mooncode-backend: - condition: service_healthy - traefik: - condition: service_healthy + mooncode-api: {condition: service_healthy} labels: - traefik.enable=true - traefik.docker.network=mooncode_edge - - traefik.http.routers.mooncode-frontend.entrypoints=web - - traefik.http.routers.mooncode-frontend.rule=Host(`mooncode.localhost`) && PathPrefix(`/`) + - traefik.http.routers.mooncode-frontend.rule=Host(`mooncode.localhost`) - traefik.http.routers.mooncode-frontend.priority=1 - traefik.http.services.mooncode-frontend.loadbalancer.server.port=80 healthcheck: - test: ["CMD", "wget", "-Y", "off", "-q", "-O", "/dev/null", "http://127.0.0.1/healthz"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/healthz"] interval: 5s timeout: 3s - retries: 10 - start_period: 3s + retries: 20 restart: unless-stopped - networks: - - edge + networks: [edge] networks: edge: + ipam: + config: + - subnet: 172.30.0.0/24 backend: internal: true + outbound: volumes: postgres-data: diff --git a/deploy/frontend.Dockerfile b/deploy/frontend.Dockerfile index d5108d7..14e4165 100644 --- a/deploy/frontend.Dockerfile +++ b/deploy/frontend.Dockerfile @@ -16,7 +16,7 @@ FROM dependencies AS build COPY frontend ./ RUN npm run build -FROM nginx:1.27-alpine +FROM nginx:1.30.4-alpine AS production COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /src/dist /usr/share/nginx/html diff --git a/deploy/postgres-init.sql b/deploy/postgres-init.sql new file mode 100644 index 0000000..1fd9eb8 --- /dev/null +++ b/deploy/postgres-init.sql @@ -0,0 +1,4 @@ +CREATE ROLE mooncode LOGIN PASSWORD 'mooncode'; +CREATE DATABASE mooncode OWNER mooncode; +CREATE ROLE hatchet LOGIN PASSWORD 'hatchet'; +CREATE DATABASE hatchet OWNER hatchet; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb75307..fe711da 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,19 +19,19 @@ "tailwind-merge": "^3.3.1" }, "devDependencies": { - "@eslint/js": "^9.38.0", + "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.1.16", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", - "eslint": "^9.38.0", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", + "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", "globals": "^16.5.0", "tailwindcss": "^4.1.16", "typescript": "~5.9.3", - "typescript-eslint": "^8.46.2", + "typescript-eslint": "^8.65.0", "vite": "^7.2.0" } }, @@ -802,118 +802,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -1765,6 +1736,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1997,45 +1975,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2091,19 +2030,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2165,36 +2091,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "18 || 20 || >=22" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.11.5", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", @@ -2209,14 +2115,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -2253,16 +2161,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001806", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", @@ -2284,23 +2182,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2322,33 +2203,6 @@ "node": ">=6" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2500,33 +2354,33 @@ } }, "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -2536,8 +2390,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2545,7 +2398,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -2580,58 +2433,60 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.26", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", - "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", "dev": true, "license": "MIT", "peerDependencies": { - "eslint": ">=8.40" + "eslint": "^9 || ^10" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2831,16 +2686,6 @@ "dev": true, "license": "ISC" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2868,23 +2713,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2942,29 +2770,6 @@ "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3313,13 +3118,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3350,16 +3148,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -3455,19 +3256,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3604,16 +3392,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rollup": { "version": "4.62.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", @@ -3708,32 +3486,6 @@ "node": ">=0.10.0" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ed561eb..e55d657 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,19 +22,19 @@ "tailwind-merge": "^3.3.1" }, "devDependencies": { - "@eslint/js": "^9.38.0", + "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.1.16", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", - "eslint": "^9.38.0", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", + "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", "globals": "^16.5.0", "tailwindcss": "^4.1.16", "typescript": "~5.9.3", - "typescript-eslint": "^8.46.2", + "typescript-eslint": "^8.65.0", "vite": "^7.2.0" } } diff --git a/frontend/scripts/check-i18n.mjs b/frontend/scripts/check-i18n.mjs index 3d577a6..627850b 100644 --- a/frontend/scripts/check-i18n.mjs +++ b/frontend/scripts/check-i18n.mjs @@ -97,21 +97,17 @@ async function checkCatalogAndFormatting() { } const formatting = await loadTypeScriptModule("src/i18n/format.ts") - assert.equal(formatting.translate(en, "auth.setup.welcome", { name: "Ada" }), "Welcome, Ada. Confirm the policies to create your personal workspace.") + assert.equal(formatting.translate(en, "auth.setup.welcome", { name: "Ada" }), "Welcome, Ada. Confirm the policies to activate your account.") assert.equal(formatting.translate(zhCN, "repositories.deleteConfirm", { name: "mooncode" }), "删除 mooncode 及其本地源码?") assert.equal(formatting.formatState(zhCN, "ready"), "已就绪") assert.equal(formatting.formatState(zhCN, "future_state"), "future_state") - assert.equal(formatting.formatRole(en, "owner"), "Owner") - assert.equal(formatting.formatRole(en, "future_role"), "future_role") assert.equal(formatting.formatError(zhCN, { code: "resource.not_found" }), "未找到请求的资源。") assert.equal(formatting.formatError(zhCN, { code: "future.error" }), zhCN["error.fallback"]) assert.equal(formatting.formatError(en, new Error("diagnostic must not leak")), en["error.fallback"]) const instant = "2026-07-29T12:34:00Z" assert.notEqual(formatting.formatDateTime("en", instant), formatting.formatDateTime("zh-CN", instant), "date formatting must follow the locale") const systemStates = ["active", "cancelled", "connected", "deleted", "deleting", "disabled", "failed", "misconfigured", "pending", "queued", "ready", "retry_wait", "running", "starting", "succeeded", "suspended", "syncing", "uploading"] - const systemRoles = ["admin", "member", "owner", "viewer"] assert.deepEqual(systemStates.filter((state) => !Object.hasOwn(en, `state.${state}`)), [], "all system states must be localizable") - assert.deepEqual(systemRoles.filter((role) => !Object.hasOwn(en, `role.${role}`)), [], "all workspace roles must be localizable") return { en, zhCN } } @@ -212,14 +208,17 @@ function collectMatches(source, expression) { } function checkBackendErrorCoverage(en) { - const roots = ["internal/controller", "internal/middleware", "internal/bootstrap"] - const files = roots.flatMap((root) => walkFiles(path.join(repositoryRoot, root), ".go")).filter((filename) => !filename.endsWith("_test.go")) + const roots = ["internal"] + const files = roots + .flatMap((root) => walkFiles(path.join(repositoryRoot, root), ".go")) + .filter((filename) => !filename.endsWith("_test.go") && !filename.includes(`${path.sep}internal${path.sep}data${path.sep}sqlc${path.sep}`)) const codes = new Set(["http.request_failed"]) const patterns = [ /writeAPIError\([^,\n]+,\s*[^,\n]+,\s*"([a-z][a-z0-9_.]+)"/g, /write(?:Service|Registration)Error\([^,\n]+,\s*[^,\n]+,\s*"([a-z][a-z0-9_.]+)"/g, /abortIdentity\([^,\n]+,\s*[^,\n]+,\s*"([a-z][a-z0-9_.]+)"/g, /"code"\s*:\s*"([a-z][a-z0-9_.]+)"/g, + /fault\.(?:New|Wrap)\(\s*[^,]+,\s*"([a-z][a-z0-9_.]+)"/g, ] for (const filename of files) { const source = fs.readFileSync(filename, "utf8") @@ -227,7 +226,7 @@ function checkBackendErrorCoverage(en) { for (const code of collectMatches(source, pattern)) codes.add(code) } } - const missing = [...codes].filter((code) => !Object.hasOwn(en, `error.${code}`)).sort() + const missing = [...codes].filter((code) => !code.endsWith(".") && !Object.hasOwn(en, `error.${code}`)).sort() assert.deepEqual(missing, [], `missing localized API error codes: ${missing.join(", ")}`) } diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 9ac746d..16c63cf 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,11 +1,17 @@ -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { useForm, useWatch } from "react-hook-form" import { + BarChart3, + CheckCircle2, ChevronDown, CircleStop, + Clock3, + Copy, + ExternalLink, + FileCode2, GitBranch, Hash, + Inbox, LayoutGrid, Link2, LoaderCircle, @@ -15,260 +21,1014 @@ import { Plus, Power, RefreshCw, + Search, Settings, Trash2, X, } from "lucide-react" -import { Button } from "@/components/ui/button" -import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher" -import { BrandMark, EmptyState, FatalState, Field, FormError, InlineLoading, Metric, PageLoading, PageTitle, StateBadge } from "@/components/shared/presentation" import { AccountRegistration, AuthenticationFailure } from "@/app/Authentication" +import { AuthenticationContinuation } from "@/app/AuthenticationContinuation" import { LandingPage } from "@/app/LandingPage" +import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher" +import { + BrandMark, + EmptyState, + ErrorState, + FatalState, + Field, + FormError, + InlineLoading, + Metric, + PageLoading, + PageTitle, + Panel, + SectionHeader, + SegmentedControl, + StateBadge, + DefinitionItem, + Drawer, +} from "@/components/shared/presentation" +import { Button } from "@/components/ui/button" +import type { TranslationKey } from "@/i18n/catalog" +import { useI18n } from "@/i18n/context" +import { logout } from "@/lib/auth" import { api } from "@/lib/api/client" -import type { ChannelInstance, ChannelStatus, Connection, IMConversation, IMMessage, Repository, Session, Workspace, WorkspaceInvitation, WorkspaceMember, WorkspaceOverview } from "@/lib/api/types" -import { cn } from "@/lib/utils" +import type { + AnalysisProfile, + AnalysisRun, + ChannelInstance, + ChannelNotificationEvent, + ChannelStatus, + IMConversation, + IMMessage, + PageResponse, + Repository, + RepositorySnapshot, + ProviderConnection, + Session, + RepositoryOperation, + Workspace, + WorkspaceInvitation, + WorkspaceMember, + WorkspaceOverview, +} from "@/lib/api/types" import { useInvitationToken } from "@/lib/invitation" -import { useI18n } from "@/i18n/context" -import type { TranslationKey } from "@/i18n/catalog" +import { cn } from "@/lib/utils" + +type PageID = "overview" | "repositories" | "analysis" | "channels" | "messages" | "settings" +type SettingsTab = "workspace" | "team" | "credentials" + +type NavigationItem = { id: PageID; labelKey: TranslationKey; icon: typeof LayoutGrid } + +const navigationGroups: Array<{ labelKey: TranslationKey; items: NavigationItem[] }> = [ + { + labelKey: "nav.qualityWorkflow", + items: [ + { id: "overview", labelKey: "nav.overview", icon: LayoutGrid }, + { id: "repositories", labelKey: "nav.repositories", icon: GitBranch }, + { id: "analysis", labelKey: "nav.analysis", icon: BarChart3 }, + ], + }, + { + labelKey: "nav.teamCollaboration", + items: [ + { id: "channels", labelKey: "nav.channels", icon: Hash }, + { id: "messages", labelKey: "nav.messages", icon: Inbox }, + ], + }, +] + +const navigation = navigationGroups.flatMap((group) => group.items) + +function isPageID(value: string | null): value is PageID { + return value === "settings" || navigation.some((item) => item.id === value) +} + +function usePagedQuery(queryKey: readonly unknown[], path: string, options: { enabled?: boolean; refetchInterval?: number | false } = {}) { + const query = useInfiniteQuery({ + queryKey, + initialPageParam: "", + queryFn: ({ pageParam }) => { + const location = new URL(path, "http://mooncode.local") + location.searchParams.set("limit", "50") + if (pageParam) location.searchParams.set("cursor", pageParam) + return api>(`${location.pathname}${location.search}`) + }, + getNextPageParam: (page) => page.nextCursor, + ...options, + }) + const items = query.data?.pages.flatMap((page) => page.items) ?? [] + + return { ...query, data: query.data ? { items } : undefined } +} -type PageID = "overview" | "repositories" | "connections" | "channels" | "messages" | "settings" +function PageMore({ query }: { query: { hasNextPage: boolean; isFetchingNextPage: boolean; fetchNextPage: () => Promise } }) { + const { t } = useI18n() + if (!query.hasNextPage) return null + + return
+} + +function formatBytes(bytes: number) { + if (!bytes) return "0 B" + const units = ["B", "KB", "MB", "GB", "TB"] + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}` +} + +function CopyAction({ value, label, compact = false }: { value: string; label: string; compact?: boolean }) { + const { t } = useI18n() + const [state, setState] = useState<"idle" | "copied" | "failed">("idle") -const navigation = [ - { id: "overview", labelKey: "nav.overview", icon: LayoutGrid }, - { id: "repositories", labelKey: "nav.repositories", icon: GitBranch }, - { id: "connections", labelKey: "nav.connections", icon: Link2 }, - { id: "channels", labelKey: "nav.channels", icon: Hash }, - { id: "messages", labelKey: "nav.messages", icon: MessageSquareText }, -] satisfies Array<{ id: PageID; labelKey: TranslationKey; icon: typeof LayoutGrid }> + useEffect(() => { + if (state === "idle") return + const timeout = window.setTimeout(() => setState("idle"), 2_000) + return () => window.clearTimeout(timeout) + }, [state]) + + const copy = async () => { + try { + await navigator.clipboard.writeText(value) + setState("copied") + } catch { + setState("failed") + } + } + const statusLabel = state === "copied" ? t("common.copied") : state === "failed" ? t("common.copyFailed") : label + const Icon = state === "copied" ? CheckCircle2 : state === "failed" ? X : Copy + + if (compact) return + + return +} export function App() { const path = window.location.pathname.replace(/\/+$/, "") || "/" + if (path === "/continue") return if (path !== "/app") return return } function Workbench() { const { t } = useI18n() - const [activeItem, setActiveItem] = useState("overview") + const [activeItem, setActiveItem] = useState(() => { + const requested = new URLSearchParams(window.location.search).get("section") + return isPageID(requested) ? requested : "overview" + }) + const [selectedRepositoryID, setSelectedRepositoryID] = useState(() => new URLSearchParams(window.location.search).get("repository") ?? "") + const [selectedSettingsTab, setSelectedSettingsTab] = useState(() => { + const requested = new URLSearchParams(window.location.search).get("settings") + return requested === "team" || requested === "credentials" ? requested : "workspace" + }) const [mobileOpen, setMobileOpen] = useState(false) const [workspaceID, setWorkspaceID] = useState("") const invitation = useInvitationToken() - const invitationToken = invitation.token + const channelLinkToken = new URLSearchParams(window.location.search).get("channelLink") ?? "" const session = useQuery({ queryKey: ["session"], queryFn: () => api("/session"), retry: false }) const currentSession = session.data + const signOut = useMutation({ + mutationFn: () => logout(currentSession?.auth.logoutUrl ?? ""), + onSuccess: () => window.location.assign("/app"), + }) const selectedWorkspace = useMemo(() => { const workspaces = currentSession?.workspaces ?? [] return workspaces.find((workspace) => workspace.id === workspaceID) ?? workspaces[0] }, [currentSession, workspaceID]) + useEffect(() => { + const restoreLocation = () => { + const parameters = new URLSearchParams(window.location.search) + const requested = parameters.get("section") + setActiveItem(isPageID(requested) ? requested : "overview") + setSelectedRepositoryID(parameters.get("repository") ?? "") + const settings = parameters.get("settings") + setSelectedSettingsTab(settings === "team" || settings === "credentials" ? settings : "workspace") + } + + window.addEventListener("popstate", restoreLocation) + return () => window.removeEventListener("popstate", restoreLocation) + }, []) + + const navigate = (page: PageID, repositoryID?: string, settingsTab?: SettingsTab) => { + const location = new URL(window.location.href) + location.searchParams.set("section", page) + if (repositoryID) location.searchParams.set("repository", repositoryID) + else location.searchParams.delete("repository") + if (page === "settings" && settingsTab) location.searchParams.set("settings", settingsTab) + else location.searchParams.delete("settings") + if (location.href !== window.location.href) window.history.pushState({}, "", location) + setActiveItem(page) + setSelectedRepositoryID(repositoryID ?? "") + setSelectedSettingsTab(settingsTab ?? "workspace") + setMobileOpen(false) + } + + const selectWorkspace = (nextWorkspaceID: string) => { + const location = new URL(window.location.href) + location.searchParams.delete("repository") + window.history.replaceState({}, "", location) + setSelectedRepositoryID("") + setWorkspaceID(nextWorkspaceID) + setMobileOpen(false) + } + if (session.isLoading) return - if (session.isError) return + if (session.isError) return if (!currentSession) return - if (invitationToken || currentSession.user.status === "pending") { - return + if (invitation.token || currentSession.user.status === "pending") { + return } if (!selectedWorkspace) return return (
- -
MoonCode
-
- -
- selectWorkspace(event.target.value)} + > {currentSession.workspaces.map((workspace) => )} - {(currentSession.user.displayName || currentSession.user.username || "M").slice(0, 1).toUpperCase()}
- + + +
-
- + {channelLinkToken && } +
) } -function WorkspacePage({ page, workspace, navigate }: { page: PageID; workspace: Workspace; navigate: (page: PageID) => void }) { - if (page === "repositories") return - if (page === "connections") return +function ChannelLinkAcceptance({ token }: { token: string }) { + const { formatError, t } = useI18n() + const acceptance = useQuery({ + queryKey: ["channel-identity-link", token], + queryFn: () => api("/channel-identity-links/accept", { method: "POST", body: JSON.stringify({ token }) }), + retry: false, + }) + const dismiss = () => { + const location = new URL(window.location.href) + location.searchParams.delete("channelLink") + window.history.replaceState({}, "", location) + window.location.reload() + } + + return
+ {acceptance.isLoading ? t("channelLink.linking") : acceptance.isError ? formatError(acceptance.error) : t("channelLink.success")} + {!acceptance.isLoading && } +
+} + +function WorkspacePage({ page, workspace, repositoryID, settingsTab, userID, navigate }: { page: PageID; workspace: Workspace; repositoryID: string; settingsTab: SettingsTab; userID: string; navigate: (page: PageID, repositoryID?: string, settingsTab?: SettingsTab) => void }) { + if (page === "repositories") return + if (page === "analysis") return if (page === "channels") return if (page === "messages") return - if (page === "settings") return + if (page === "settings") return return } function Overview({ workspace, navigate }: { workspace: Workspace; navigate: (page: PageID) => void }) { const { t } = useI18n() - const repositories = useQuery({ queryKey: ["repositories", workspace.id], queryFn: () => api<{ items: Repository[] }>(`/workspaces/${workspace.id}/repositories`) }) - const overview = useQuery({ queryKey: ["overview", workspace.id], queryFn: () => api(`/workspaces/${workspace.id}/overview`), refetchInterval: 5_000 }) + const repositories = usePagedQuery(["repositories", workspace.id], `/workspaces/${workspace.id}/repositories`) + const overview = useQuery({ + queryKey: ["overview", workspace.id], + queryFn: () => api(`/workspaces/${workspace.id}/overview`), + refetchInterval: 5_000, + }) const values = repositories.data?.items ?? [] const recentMessages = overview.data?.recentMessages ?? [] - return
- navigate("repositories")}>{t("repositories.add")}} /> -

{t("overview.getStarted")}

{t("overview.heroTitle")}

{t("overview.heroDescription")}

-
-

{t("overview.recentRepositories")}

{t("overview.recentMessages")}

{recentMessages.length ?
{recentMessages.map((item) => )}
: }
+ const synchronizedCount = overview.data + ? Math.max(overview.data.repositoryCount - overview.data.repositoryWithoutCodeCount, 0) + : values.filter((repository) => repository.currentSnapshotId).length + const setupSteps = [ + { complete: values.length > 0, label: t("overview.setupRepository"), action: () => navigate("repositories") }, + { complete: synchronizedCount > 0, label: t("overview.setupSync"), action: () => navigate("repositories") }, + { complete: (overview.data?.activeChannelCount ?? 0) > 0, label: t("overview.setupChannel"), action: () => navigate("channels") }, + ] + const completedSteps = setupSteps.filter((step) => step.complete).length + const attentionCount = (overview.data?.repositoryWithoutCodeCount ?? 0) + (overview.data?.recentFailedSyncCount ?? 0) + const pageTitle = navigate("repositories")}>{t("repositories.add")}} + /> + + if (repositories.isLoading || overview.isLoading) return
+ {pageTitle} +
+
+ + if (repositories.isError || overview.isError) return
+ {pageTitle} +
{ void repositories.refetch(); void overview.refetch() }} />
+
+ + return
+ {pageTitle} +
+ + +
+ {(overview.data?.recentFailedSyncCount ?? 0) > 0 && } + {(overview.data?.repositoryWithoutCodeCount ?? 0) > 0 && } + {attentionCount === 0 &&

{t("overview.noAttention")}

} +
+
+ + +
+
{setupSteps.map((step) => )}
+ +
+
+ + + + + +
+
+ + navigate("repositories")}>{t("common.viewAll")}} /> +
+
+ + navigate("messages")}>{t("common.viewAll")}} /> +
{recentMessages.length + ?
{recentMessages.map((item) => )}
+ : }
+
+
} -type RepositoryForm = { name: string; cloneUrl: string; providerType: string; ref: string } +function QualityAnalysis({ workspace, selectedRepositoryID, navigate }: { workspace: Workspace; selectedRepositoryID: string; navigate: (page: PageID, repositoryID?: string) => void }) { + const { t } = useI18n() + const [profileDrawerOpen, setProfileDrawerOpen] = useState(false) + const repositories = usePagedQuery(["repositories", workspace.id], `/workspaces/${workspace.id}/repositories`, { refetchInterval: 4_000 }) + const profiles = usePagedQuery(["analysis-profiles", workspace.id], `/workspaces/${workspace.id}/analysis-profiles`) + const items = repositories.data?.items ?? [] + const selectedRepository = items.find((repository) => repository.id === selectedRepositoryID) + ?? items[0] + + return
+ setProfileDrawerOpen(true)}>{t("analysis.manageProfiles")}} /> + {repositories.isLoading + ?
+ : repositories.isError + ?
void repositories.refetch()} />
+ : profiles.isError + ?
void profiles.refetch()} />
+ : !items.length + ?
+ :
+ + {selectedRepository && setProfileDrawerOpen(true)} />} +
} + setProfileDrawerOpen(false)} /> +
+} -function Repositories({ workspace }: { workspace: Workspace }) { +function AnalysisProfiles({ workspaceID, open, onClose }: { workspaceID: string; open: boolean; onClose: () => void }) { const { formatError, t } = useI18n() const queryClient = useQueryClient() const [adding, setAdding] = useState(false) - const { register, handleSubmit, reset } = useForm({ - defaultValues: { name: "", cloneUrl: "", providerType: "github", ref: "" }, + const [name, setName] = useState("") + const [timeoutSeconds, setTimeoutSeconds] = useState("300") + const profiles = usePagedQuery(["analysis-profiles", workspaceID], `/workspaces/${workspaceID}/analysis-profiles`) + const profileItems = profiles.data?.items ?? [] + const create = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/analysis-profiles`, { + method: "POST", + body: JSON.stringify({ name: name.trim(), definition: { dimensionKey: "code_scale", timeoutSeconds: Number(timeoutSeconds), analyzer: { key: "scc", required: true } } }), + }), + onSuccess: () => { + setAdding(false) + setName("") + setTimeoutSeconds("300") + void queryClient.invalidateQueries({ queryKey: ["analysis-profiles", workspaceID] }) + }, }) - const repositories = useQuery({ - queryKey: ["repositories", workspace.id], - queryFn: () => api<{ items: Repository[] }>(`/workspaces/${workspace.id}/repositories`), - refetchInterval: 5_000, + + return +
+ {adding &&
+ setName(event.target.value)} /> + setTimeoutSeconds(event.target.value)} /> + {create.isError &&
} +
+
} +
+ {profiles.isLoading + ? + : profiles.isError + ? void profiles.refetch()} /> + : !profileItems.length + ? + :
{profileItems.map((profile) => 1} />)}
} +
+ +
+} + +function AnalysisProfileRow({ profile, workspaceID, canArchive }: { profile: AnalysisProfile; workspaceID: string; canArchive: boolean }) { + const { formatError, t } = useI18n() + const queryClient = useQueryClient() + const [editing, setEditing] = useState(false) + const [name, setName] = useState(profile.name) + const [timeoutSeconds, setTimeoutSeconds] = useState(String(profile.definition.timeoutSeconds)) + const update = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/analysis-profiles/${profile.id}/versions`, { + method: "POST", + body: JSON.stringify({ currentVersion: profile.currentVersion, name: name.trim(), definition: { dimensionKey: "code_scale", timeoutSeconds: Number(timeoutSeconds), analyzer: { key: "scc", required: true } } }), + }), + onSuccess: () => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ["analysis-profiles", workspaceID] }) }, }) + const archive = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/analysis-profiles/${profile.id}`, { method: "DELETE" }), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["analysis-profiles", workspaceID] }), + }) + const error = update.error ?? archive.error + + return
+

{profile.name}

{t("analysis.profileMetadata", { version: profile.currentVersion, timeout: profile.definition.timeoutSeconds })}

+ {editing &&
setName(event.target.value)} /> setTimeoutSeconds(event.target.value)} />{error &&
}
} +
+} + +function AnalysisWorkspace({ workspaceID, repository, profiles, profilesLoading, onManageProfiles }: { workspaceID: string; repository: Repository; profiles: AnalysisProfile[]; profilesLoading: boolean; onManageProfiles: () => void }) { + const { formatDateTime, formatError, t } = useI18n() + const queryClient = useQueryClient() + const [snapshotID, setSnapshotID] = useState("") + const [profileID, setProfileID] = useState("") + const [viewedRunID, setViewedRunID] = useState("") + const snapshots = usePagedQuery(["repository-snapshots", repository.id], `/workspaces/${workspaceID}/repositories/${repository.id}/snapshots`, { enabled: !!repository.currentSnapshotId }) + const availableSnapshots = snapshots.data?.items.filter((snapshot) => snapshot.sourceState === "available") ?? [] + const selectedProfileID = profiles.some((profile) => profile.id === profileID) ? profileID : profiles[0]?.id || "" + const selectedProfile = profiles.find((profile) => profile.id === selectedProfileID) + const selectedSnapshotID = availableSnapshots.some((snapshot) => snapshot.id === snapshotID) ? snapshotID : repository.currentSnapshotId || "" + const analyses = usePagedQuery(["analysis-runs", repository.id], `/workspaces/${workspaceID}/repositories/${repository.id}/analysis-runs`, { enabled: !!repository.currentSnapshotId, refetchInterval: 4_000 }) + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ["analysis-runs", repository.id] }) + void queryClient.invalidateQueries({ queryKey: ["overview", workspaceID] }) + } + const analyze = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${repository.id}/analysis-runs`, { method: "POST", body: JSON.stringify({ profileId: selectedProfileID, snapshotId: selectedSnapshotID }) }), + onSuccess: invalidate, + }) + const cancel = useMutation({ + mutationFn: (runID: string) => api(`/workspaces/${workspaceID}/analysis-runs/${runID}/cancel`, { method: "POST", body: "{}" }), + onSuccess: invalidate, + }) + const retry = useMutation({ + mutationFn: (runID: string) => api(`/workspaces/${workspaceID}/analysis-runs/${runID}/retry`, { method: "POST", body: "{}" }), + onSuccess: () => { setViewedRunID(""); invalidate() }, + }) + const latestForSelection = analyses.data?.items.find((item) => item.snapshotId === selectedSnapshotID && item.profileId === selectedProfileID && item.profileVersion === `v${selectedProfile?.currentVersion ?? ""}`) + const viewedRun = analyses.data?.items.find((item) => item.id === viewedRunID) ?? latestForSelection + const error = analyze.error ?? cancel.error ?? retry.error + const analysisDataError = snapshots.error ?? analyses.error + const analysisDataLoading = snapshots.isLoading || analyses.isLoading + const snapshotSelectionReady = availableSnapshots.some((snapshot) => snapshot.id === selectedSnapshotID) + + const summary = viewedRun?.report?.result.summary + const languages = viewedRun?.report?.result.languages ?? [] + const warnings = viewedRun?.report?.result.warnings ?? [] + + return
+
+

{repository.name}

{repository.remoteUrl}

+ {latestForSelection && } +
+
+ + +
{latestForSelection && (latestForSelection.status === "queued" || latestForSelection.status === "running") + ? + : latestForSelection?.retryable + ? + : }
+
+ {!repository.currentSnapshotId &&
} + {repository.currentSnapshotId && analysisDataLoading &&
} + {repository.currentSnapshotId && analysisDataError &&
{ void snapshots.refetch(); void analyses.refetch() }} />
} + {repository.currentSnapshotId && !analysisDataLoading && !analysisDataError && !availableSnapshots.length &&
} + {repository.currentSnapshotId && !profilesLoading && !profiles.length &&
{t("analysis.addProfile")}} />
} + {latestForSelection?.errorMessage &&
{latestForSelection.errorMessage}
} + {error && } + {summary ? <> +

{t("analysis.reportTitle")}

{t("analysis.reportSelection", { commit: viewedRun?.commitSha.slice(0, 12) ?? "", profile: viewedRun?.profileVersion ?? "" })}

{viewedRunID && }
+
+ + + + +
+
+ + +
{languages.map((language) => { + const percentage = summary.code > 0 ? (language.code / summary.code) * 100 : 0 + return

{language.name}

{t("analysis.languageFiles", { count: language.files })}

0 ? 1 : 0)}%` }} />
{t("analysis.languageShare", { code: language.code.toLocaleString(), percentage: percentage.toFixed(1) })}
+ })}
+ + {warnings.length > 0 &&
    {warnings.map((warning, index) =>
  • {warning}
  • )}
} + : repository.currentSnapshotId && !analysisDataLoading && !analysisDataError && availableSnapshots.length > 0 && profiles.length > 0 && !viewedRun ?
: viewedRun && !summary ?
{viewedRun.status === "queued" || viewedRun.status === "running" ? : }

{t("analysis.runStatus")}

{t("analysis.runStatusDescription", { stage: viewedRun.stage, attempt: viewedRun.attempt })}

{viewedRun.errorMessage &&

{viewedRun.errorMessage}

}
: null} + {(analyses.data?.items.length ?? 0) > 0 &&
{analyses.data?.items.map((run) =>
{run.report && }{(run.status === "queued" || run.status === "running") && }{run.retryable && }
)}
} + +
+} + +function ProviderConnectionSettings() { + const { formatError, t } = useI18n() + const queryClient = useQueryClient() + const [adding, setAdding] = useState(false) + const [providerType, setProviderType] = useState<"github" | "gitlab">("github") + const [baseUrl, setBaseUrl] = useState("https://github.com") + const [token, setToken] = useState("") + const accounts = usePagedQuery(["provider-connections"], "/me/provider-connections") const create = useMutation({ - mutationFn: (form: RepositoryForm) => api(`/workspaces/${workspace.id}/repositories`, { method: "POST", body: JSON.stringify(form) }), + mutationFn: () => api("/me/provider-connections", { method: "POST", body: JSON.stringify({ providerType, baseUrl, token }) }), onSuccess: () => { setAdding(false) - reset() - void queryClient.invalidateQueries({ queryKey: ["repositories", workspace.id] }) + setToken("") + void queryClient.invalidateQueries({ queryKey: ["provider-connections"] }) }, }) - return
- setAdding((value) => !value)}>{adding ? : }{t(adding ? "common.cancel" : "repositories.add")}} /> - {adding &&
create.mutate(form))}> -
- - - - + return
+
+

{t("providerConnections.title")}

{t("providerConnections.description")}

+ +
+ {adding &&
+ + + + + setToken(event.target.value)} /> + {create.isError &&
} +
+
} +
+ {accounts.isLoading + ? + : accounts.isError + ? void accounts.refetch()} /> + : !accounts.data?.items.length + ? + :
{accounts.data.items.map((account) => )}
} + +
+
+} + +function ProviderConnectionRow({ account }: { account: ProviderConnection }) { + const { formatError, t } = useI18n() + const queryClient = useQueryClient() + const [replacing, setReplacing] = useState(false) + const [token, setToken] = useState("") + const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["provider-connections"] }) + const remove = useMutation({ mutationFn: () => api(`/me/provider-connections/${account.id}`, { method: "DELETE" }), onSuccess: invalidate }) + const test = useMutation({ mutationFn: () => api(`/me/provider-connections/${account.id}/test`, { method: "POST", body: "{}" }), onSuccess: invalidate }) + const setDefault = useMutation({ mutationFn: () => api(`/me/provider-connections/${account.id}/default`, { method: "POST", body: "{}" }), onSuccess: invalidate }) + const replace = useMutation({ + mutationFn: () => api(`/me/provider-connections/${account.id}`, { method: "PATCH", body: JSON.stringify({ token }) }), + onSuccess: () => { setReplacing(false); setToken(""); invalidate() }, + }) + const title = account.displayName || account.login || account.providerType + const error = replace.error ?? remove.error ?? test.error ?? setDefault.error + + return
+
+
+
+

{title}

{account.isDefault && {t("providerConnections.default")}}
+

{account.providerType} · {account.baseUrl}

+

{t("providerConnections.privateHint")}

- {create.isError && } -
- } -
+ +
+
+ + {!account.isDefault && } + + +
+ {test.isSuccess &&

{t("providerConnections.testSucceeded")}

} + {replacing &&
+ setToken(event.target.value)} /> + +
} + {error && }
} -function RepositoryList({ items, loading = false, compact = false, workspaceID }: { items: Repository[]; loading?: boolean; compact?: boolean; workspaceID?: string }) { +function Repositories({ workspace, selectedRepositoryID, navigate }: { workspace: Workspace; selectedRepositoryID: string; navigate: (page: PageID, repositoryID?: string) => void }) { const { t } = useI18n() - const queryClient = useQueryClient(); const sync = useMutation({ mutationFn: ({ id, ref }: { id: string; ref: string }) => api(`/workspaces/${workspaceID}/repositories/${id}/sync`, { method: "POST", body: JSON.stringify({ ref }) }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["repositories", workspaceID] }) }) - const cancel = useMutation({ mutationFn: (id: string) => api(`/workspaces/${workspaceID}/repositories/${id}/cancel-sync`, { method: "POST", body: "{}" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["repositories", workspaceID] }) }) - const remove = useMutation({ mutationFn: (id: string) => api(`/workspaces/${workspaceID}/repositories/${id}`, { method: "DELETE" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["repositories", workspaceID] }) }) - if (loading) return - if (!items.length) return - return
{items.map((item) =>

{item.name}

{item.cloneUrl}

{item.ref || t("repositories.remoteDefault")} · {item.currentCommitSha?.slice(0, 12) || t("repositories.notSynced")}

{item.lastErrorMessage &&

{item.lastErrorMessage}

}
{!compact && <>{item.state === "syncing" ? : }}
)}
-} + const [adding, setAdding] = useState(false) + const [search, setSearch] = useState("") + const repositories = usePagedQuery(["repositories", workspace.id], `/workspaces/${workspace.id}/repositories`, { refetchInterval: 4_000 }) + const items = repositories.data?.items ?? [] + const filteredItems = items.filter((repository) => `${repository.name} ${repository.remoteUrl} ${repository.ref}`.toLowerCase().includes(search.trim().toLowerCase())) + const selected = items.find((repository) => repository.id === selectedRepositoryID) ?? items[0] -type ConnectionForm = { name: string; type: string; baseUrl: string; authType: string; username: string; token: string } + return
+ setAdding(true)}>{t("repositories.add")}} + /> + {repositories.isLoading + ?
+ : repositories.isError + ?
void repositories.refetch()} />
+ : !items.length + ?
setAdding(true)}>{t("repositories.add")}} />
+ :
+ + {selected && } +
} + setAdding(false)}> setAdding(false)} /> +
+} -function Connections({ workspace }: { workspace: Workspace }) { +function RepositoryCreate({ workspace, onDone }: { workspace: Workspace; onDone: () => void }) { const { formatError, t } = useI18n() const queryClient = useQueryClient() - const [adding, setAdding] = useState(false) - const { register, handleSubmit, reset, setValue } = useForm({ - defaultValues: { name: "", type: "github", baseUrl: "https://github.com", authType: "token", username: "", token: "" }, - }) - const connections = useQuery({ queryKey: ["connections", workspace.id], queryFn: () => api<{ items: Connection[] }>(`/workspaces/${workspace.id}/scm-connections`) }) + const [remoteUrl, setRemoteUrl] = useState("") + const [repositoryRef, setRepositoryRef] = useState("") + const [name, setName] = useState("") + const [providerConnectionId, setProviderConnectionId] = useState("") + const connections = usePagedQuery(["provider-connections"], "/me/provider-connections") + const activeConnections = (connections.data?.items ?? []).filter((item) => item.status === "active") const create = useMutation({ - mutationFn: (form: ConnectionForm) => api(`/workspaces/${workspace.id}/scm-connections`, { method: "POST", body: JSON.stringify({ name: form.name, type: form.type, baseUrl: form.baseUrl, authType: form.authType, secrets: form.token ? { username: form.username, token: form.token } : {} }) }), + mutationFn: () => api(`/workspaces/${workspace.id}/repositories`, { + method: "POST", + body: JSON.stringify({ + name: name.trim(), + remoteUrl: remoteUrl.trim(), + ref: repositoryRef.trim(), + ...(providerConnectionId ? { providerConnectionId } : {}), + }), + }), onSuccess: () => { - setAdding(false) - reset() - void queryClient.invalidateQueries({ queryKey: ["connections", workspace.id] }) + void queryClient.invalidateQueries({ queryKey: ["repositories", workspace.id] }) + onDone() }, }) - return
setAdding((value) => !value)}>{t("connections.new")}} /> - {adding &&
create.mutate(form))}>
{create.isError && }
} -
{connections.isLoading ? : !connections.data?.items.length ? :
{connections.data.items.map((item) => )}
}
+ + return
+ + setRemoteUrl(event.target.value)} placeholder="https://github.com/owner/repository.git" /> + + setRepositoryRef(event.target.value)} placeholder={t("repositories.targetExample")} /> + setName(event.target.value)} placeholder={t("repositories.namePlaceholder")} /> + +
+ {create.isError &&
} +
} -function ConnectionRow({ item, workspaceID }: { item: Connection; workspaceID: string }) { - const { formatError, t } = useI18n() - const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [name, setName] = useState(item.name); const [token, setToken] = useState(""); const [repositoryUrl, setRepositoryUrl] = useState("") - const update = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/scm-connections/${item.id}`, { method: "PATCH", body: JSON.stringify({ name, ...(token ? { secrets: { token } } : {}) }) }), onSuccess: () => { setToken(""); void queryClient.invalidateQueries({ queryKey: ["connections", workspaceID] }) } }) - const probe = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/scm-connections/${item.id}/test`, { method: "POST", body: JSON.stringify({ repositoryUrl }) }) }) - const remove = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/scm-connections/${item.id}`, { method: "DELETE" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["connections", workspaceID] }) }) - return
{t(item.secretConfigured ? "connections.credentialConfigured" : "connections.publicAccess")}
{open &&
setName(event.target.value)} /> setToken(event.target.value)} placeholder={t("connections.keepToken")} />
setRepositoryUrl(event.target.value)} placeholder={`${item.baseUrl}/owner/repository.git`} />
{probe.isSuccess &&

{t("connections.succeeded")}

}{(probe.isError || update.isError || remove.isError) && }
}
+function RepositoryList({ items, loading = false, compact = false }: { items: Repository[]; loading?: boolean; compact?: boolean }) { + const { t } = useI18n() + if (loading) return + if (!items.length) return + return
+ {items.map((item) =>

{item.name}

{item.ref} · {item.currentSnapshot?.commitSha.slice(0, 12) || t("repositories.notSynced")}

)} +
+} + +function RepositoryDetail({ item, workspaceID, navigate }: { item: Repository; workspaceID: string; navigate: (page: PageID, repositoryID?: string) => void }) { + const { formatDateTime, formatError, t } = useI18n() + const queryClient = useQueryClient() + const [tab, setTab] = useState<"activity" | "settings">("activity") + const [repositoryName, setRepositoryName] = useState(item.name) + const [repositoryRemoteUrl, setRepositoryRemoteUrl] = useState(item.remoteUrl) + const [repositoryRef, setRepositoryRef] = useState(item.ref) + const [providerConnectionId, setProviderConnectionId] = useState("") + const connections = usePagedQuery(["provider-connections"], "/me/provider-connections") + const activeConnections = (connections.data?.items ?? []).filter((connection) => connection.status === "active") + const operations = usePagedQuery(["repository-operations", item.id], `/workspaces/${workspaceID}/repositories/${item.id}/operations`, { refetchInterval: 4_000 }) + const latestOperation = operations.data?.items[0] + + const invalidateRepository = () => { + void queryClient.invalidateQueries({ queryKey: ["repositories", workspaceID] }) + void queryClient.invalidateQueries({ queryKey: ["repository-operations", item.id] }) + } + const refresh = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}/refresh`, { + method: "POST", + body: JSON.stringify(providerConnectionId ? { providerConnectionId } : {}), + }), + onSuccess: invalidateRepository, + }) + const rename = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}`, { method: "PATCH", body: JSON.stringify({ name: repositoryName }) }), + onSuccess: invalidateRepository, + }) + const archive = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}/${item.archivedAt ? "restore" : "archive"}`, { method: "POST", body: "{}" }), + onSuccess: invalidateRepository, + }) + const remove = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}`, { method: "DELETE" }), + onSuccess: () => { invalidateRepository(); navigate("repositories") }, + }) + const updateSource = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}`, { + method: "PATCH", + body: JSON.stringify({ + remoteUrl: repositoryRemoteUrl, + ref: repositoryRef, + ...(providerConnectionId ? { providerConnectionId } : {}), + }), + }), + onSuccess: invalidateRepository, + }) + const cancelOperation = useMutation({ mutationFn: (operationID: string) => api(`/workspaces/${workspaceID}/repositories/${item.id}/operations/${operationID}/cancel`, { method: "POST", body: "{}" }), onSuccess: invalidateRepository }) + const mutationError = refresh.error ?? rename.error ?? archive.error ?? remove.error ?? updateSource.error ?? cancelOperation.error + + return
+

{item.name}

{item.archivedAt && }
{item.remoteUrl}
{latestOperation && (latestOperation.status === "queued" || latestOperation.status === "running") ? : }
+
{item.currentSnapshot.commitSha} : t("repositories.notSynced")} mono />
+ {item.lastErrorMessage &&
{item.lastErrorMessage}
} + {mutationError && } +
+ {tab === "activity" ? {operations.isLoading ? : operations.isError ?
void operations.refetch()} />
: !operations.data?.items.length ?
:
{operations.data.items.map((operation) => cancelOperation.mutate(operation.id)} />)}
}
:
+
setRepositoryName(event.target.value)} />
+
setRepositoryRemoteUrl(event.target.value)} /> setRepositoryRef(event.target.value)} />
+
+
} +
} -type ChannelForm = { type: string; name: string; clientId: string; clientSecret: string; senderAllowList: string; requireMention: boolean; prefix: string } +function RepositoryOperationRow({ operation, onCancel }: { operation: RepositoryOperation; onCancel: () => void }) { + const { formatDateTime, t } = useI18n() + const active = operation.status === "queued" || operation.status === "running" + const kind = t(operation.kind === "provision" ? "repositories.operationProvision" : operation.kind === "refresh" ? "repositories.operationRefresh" : operation.kind === "update" ? "repositories.operationUpdate" : "repositories.operationPurge") + const outcome = operation.outcome ? t(operation.outcome === "changed" ? "repositories.outcomeChanged" : "repositories.outcomeNoChange") : "" + return
+ + {kind}{outcome && {outcome}}{formatDateTime(operation.createdAt)}{operation.resolvedCommitSha ? ` · ${operation.resolvedCommitSha.slice(0, 12)}` : ""}{operation.errorMessage && {operation.errorMessage}} + {active && } +
+} + +type ChannelForm = { type: string; name: string; clientId: string; clientSecret: string; targetId: string; robotCode: string; isLark: boolean; senderAllowList: string; requireMention: boolean; prefix: string; notificationEvents: ChannelNotificationEvent[] } + +const channelNotificationOptions = [ + { event: "analysis.started", label: "channels.notifyStarted" }, + { event: "analysis.succeeded", label: "channels.notifySucceeded" }, + { event: "analysis.failed", label: "channels.notifyFailed" }, +] as const + +const defaultChannelNotificationEvents: ChannelNotificationEvent[] = channelNotificationOptions.map(({ event }) => event) function Channels({ workspace }: { workspace: Workspace }) { const { formatError, t } = useI18n() const queryClient = useQueryClient() const [adding, setAdding] = useState(false) - const { register, handleSubmit, reset, control } = useForm({ defaultValues: { type: "feishu", name: "", clientId: "", clientSecret: "", senderAllowList: "*", requireMention: true, prefix: "/moon" } }) - const channelType = useWatch({ control, name: "type" }) - const channels = useQuery({ queryKey: ["channels", workspace.id], queryFn: () => api<{ items: ChannelInstance[] }>(`/workspaces/${workspace.id}/channels`), refetchInterval: 5_000 }) + const [form, setForm] = useState({ type: "feishu", name: "", clientId: "", clientSecret: "", targetId: "", robotCode: "", isLark: false, senderAllowList: "*", requireMention: true, prefix: "/moon", notificationEvents: defaultChannelNotificationEvents }) + const channels = usePagedQuery(["channels", workspace.id], `/workspaces/${workspace.id}/channels`, { refetchInterval: 5_000 }) const create = useMutation({ - mutationFn: (form: ChannelForm) => api(`/workspaces/${workspace.id}/channels`, { + mutationFn: () => api(`/workspaces/${workspace.id}/channels`, { method: "POST", body: JSON.stringify({ type: form.type, name: form.name, - values: form.type === "feishu" ? { app_id: form.clientId, is_lark: false } : { client_id: form.clientId }, + values: form.type === "feishu" + ? { app_id: form.clientId, is_lark: form.isLark, receive_id: form.targetId, receive_id_type: "chat_id" } + : { client_id: form.clientId, robot_code: form.robotCode, open_conversation_id: form.targetId }, secrets: form.type === "feishu" ? { app_secret: form.clientSecret } : { client_secret: form.clientSecret }, senderAllowList: form.senderAllowList.split(",").map((value) => value.trim()).filter(Boolean), groupPolicy: { requireMention: form.requireMention, prefix: form.prefix }, + notificationEvents: form.notificationEvents, }), }), - onSuccess: () => { setAdding(false); reset(); void queryClient.invalidateQueries({ queryKey: ["channels", workspace.id] }) }, + onSuccess: () => { + setAdding(false) + setForm({ type: "feishu", name: "", clientId: "", clientSecret: "", targetId: "", robotCode: "", isLark: false, senderAllowList: "*", requireMention: true, prefix: "/moon", notificationEvents: defaultChannelNotificationEvents }) + void queryClient.invalidateQueries({ queryKey: ["channels", workspace.id] }) + }, }) - return
setAdding((value) => !value)}>{t("channels.add")}} /> - {adding &&
create.mutate(form))}>
{t("channels.allowedSendersHelp")}
{create.isError && }
} -
{channels.isLoading ? : !channels.data?.items.length ? :
{channels.data.items.map((item) => )}
}
+ + return
+ setAdding(true)}>{t("channels.add")}} /> + setAdding(false)}>
+ + setForm({ ...form, name: event.target.value })} placeholder={t("channels.engineeringBot")} /> + setForm({ ...form, clientId: event.target.value })} autoComplete="off" /> + setForm({ ...form, clientSecret: event.target.value })} autoComplete="new-password" /> + {form.type === "dingtalk" && setForm({ ...form, robotCode: event.target.value })} autoComplete="off" />} + setForm({ ...form, targetId: event.target.value })} autoComplete="off" /> + {form.type === "feishu" && } + setForm({ ...form, senderAllowList: event.target.value })} />{t("channels.allowedSendersHelp")} + setForm({ ...form, prefix: event.target.value })} /> + +

{t("channels.notifications")}

{channelNotificationOptions.map((option) => )}
+ {create.isError &&
} +
+
+
+ {channels.isLoading + ? + : channels.isError + ? void channels.refetch()} /> + : !channels.data?.items.length + ? setAdding(true)}>{t("channels.add")}} /> + :
{channels.data.items.map((item) => )}
} + +
} function ChannelRow({ item, workspaceID }: { item: ChannelInstance; workspaceID: string }) { - const { formatError, t } = useI18n() - const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const [name, setName] = useState(item.name); const [allowList, setAllowList] = useState(item.config.senderAllowList.join(", ")); const [prefix, setPrefix] = useState(item.config.groupPolicy.prefix); const [requireMention, setRequireMention] = useState(item.config.groupPolicy.requireMention) + const { formatDateTime, formatError, t } = useI18n() + const queryClient = useQueryClient() + const [editing, setEditing] = useState(false) + const [name, setName] = useState(item.name) + const [allowList, setAllowList] = useState(item.config.senderAllowList.join(", ")) + const [prefix, setPrefix] = useState(item.config.groupPolicy.prefix) + const [requireMention, setRequireMention] = useState(item.config.groupPolicy.requireMention) + const [notificationEvents, setNotificationEvents] = useState(item.notificationEvents) + const [replacementSecret, setReplacementSecret] = useState("") const status = useQuery({ queryKey: ["channel-status", item.id], queryFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}/status`), refetchInterval: item.enabled ? 5_000 : false }) - const toggle = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}/${item.enabled ? "disable" : "enable"}`, { method: "POST", body: "{}" }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }); void queryClient.invalidateQueries({ queryKey: ["channel-status", item.id] }) } }) - const update = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}`, { method: "PATCH", body: JSON.stringify({ version: item.configVersion, name, senderAllowList: allowList.split(",").map((value) => value.trim()).filter(Boolean), groupPolicy: { prefix, requireMention } }) }), onSuccess: () => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }) } }) + const toggle = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}/${item.enabled ? "disable" : "enable"}`, { method: "POST", body: "{}" }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }) + void queryClient.invalidateQueries({ queryKey: ["channel-status", item.id] }) + }, + }) + const update = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}`, { + method: "PATCH", + body: JSON.stringify({ version: item.configVersion, name, values: item.config.values, senderAllowList: allowList.split(",").map((value) => value.trim()).filter(Boolean), groupPolicy: { prefix, requireMention }, notificationEvents }), + }), + onSuccess: () => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }) }, + }) + const rotateCredential = useMutation({ + mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}/credentials`, { + method: "PUT", + body: JSON.stringify({ + version: item.configVersion, + secrets: item.type === "feishu" ? { app_secret: replacementSecret } : { client_secret: replacementSecret }, + }), + }), + onSuccess: () => { + setReplacementSecret("") + void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }) + void queryClient.invalidateQueries({ queryKey: ["channel-status", item.id] }) + }, + }) const remove = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}`, { method: "DELETE" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }) }) - const state = item.enabled ? status.data?.state ?? "starting" : "disabled" - return

{item.name}

{item.type} · {t(item.secretConfigured ? "connections.credentialConfigured" : "channels.credentialMissing")}

{status.data?.lastErrorMessage &&

{status.data.lastErrorMessage}

}
{editing &&
setName(event.target.value)} /> setAllowList(event.target.value)} /> setPrefix(event.target.value)} />{(update.isError || remove.isError) && }
}
+ const state = status.isError ? "error" : item.enabled ? status.data?.state ?? "starting" : "disabled" + + return +

{item.name}

{item.type}

+
+ {status.data?.lastErrorMessage &&

{status.data.lastErrorMessage}

} + {status.isError &&

{formatError(status.error)}

} + {toggle.isError && } +
{item.enabled ? t("channels.receivingMessages") : t("channels.notReceivingMessages")}
+ setEditing(false)}>
+ setName(event.target.value)} /> + setAllowList(event.target.value)} /> + setPrefix(event.target.value)} /> + +

{t("channels.notifications")}

{channelNotificationOptions.map((option) => )}
+ setReplacementSecret(event.target.value)} autoComplete="new-password" placeholder={t("channels.replacementSecret")} /> +
+ {(update.isError || rotateCredential.isError || remove.isError || toggle.isError) &&
} +
+
+
} function Messages({ workspace }: { workspace: Workspace }) { const { t } = useI18n() const [channelID, setChannelID] = useState("") const [conversationID, setConversationID] = useState("") - const channels = useQuery({ queryKey: ["channels", workspace.id], queryFn: () => api<{ items: ChannelInstance[] }>(`/workspaces/${workspace.id}/channels`) }) - const conversations = useQuery({ queryKey: ["conversations", workspace.id], queryFn: () => api<{ items: IMConversation[] }>(`/workspaces/${workspace.id}/conversations`) }) + const channels = usePagedQuery(["channels", workspace.id], `/workspaces/${workspace.id}/channels`) + const conversations = usePagedQuery(["conversations", workspace.id], `/workspaces/${workspace.id}/conversations`) const messages = useInfiniteQuery({ queryKey: ["messages", workspace.id, channelID, conversationID], initialPageParam: "", queryFn: ({ pageParam }) => { - const query = new URLSearchParams({ pageSize: "30" }) + const query = new URLSearchParams({ limit: "30" }) if (pageParam) query.set("cursor", pageParam) if (channelID) query.set("channelId", channelID) if (conversationID) query.set("conversationId", conversationID) @@ -279,28 +1039,76 @@ function Messages({ workspace }: { workspace: Workspace }) { }) const visibleConversations = (conversations.data?.items ?? []).filter((item) => !channelID || item.channelInstanceId === channelID) const items = messages.data?.pages.flatMap((page) => page.items) ?? [] - return
{messages.isLoading ? : !items.length ? : <>
{items.map((item) => )}
{messages.hasNextPage &&
}}
+ const selectedConversation = conversations.data?.items.find((conversation) => conversation.id === conversationID) + + return
+ +
+ +

{selectedConversation?.title || selectedConversation?.externalId || t("messages.allMessages")}

{selectedConversation ? t("messages.conversationVia", { channel: selectedConversation.channelName }) : t("messages.streamDescription")}

{messages.isLoading ? : messages.isError ? void messages.refetch()} /> : !items.length ? :
{items.map((item) => )}
}{messages.hasNextPage &&
}
+
+
} function MessageRow({ item, compact = false }: { item: IMMessage; compact?: boolean }) { const { formatDateTime, t } = useI18n() - return
{item.senderDisplayName || item.senderCanonicalId}{t("messages.via", { channel: item.channelName })}

{item.content.content.text || `[${item.content.content.type}]`}

+ if (!compact) return
{(item.senderDisplayName || item.senderCanonicalId).slice(0, 1).toUpperCase()}
{item.senderDisplayName || item.senderCanonicalId}{t("messages.via", { channel: item.channelName })} · {formatDateTime(item.occurredAt)}

{item.content.content.text || `[${item.content.content.type}]`}

+ + return
+
+
{item.senderDisplayName || item.senderCanonicalId}{t("messages.via", { channel: item.channelName })}
+ +
+

{item.content.content.text || `[${item.content.content.type}]`}

+
} -function WorkspaceSettings({ workspace }: { workspace: Workspace }) { - const { formatDateTime, formatError, formatRole, t } = useI18n() +function WorkspaceSettings({ workspace, userID, initialTab, navigate }: { workspace: Workspace; userID: string; initialTab: SettingsTab; navigate: (page: PageID, repositoryID?: string, settingsTab?: SettingsTab) => void }) { + const { formatDateTime, formatError, t } = useI18n() const queryClient = useQueryClient() const [name, setName] = useState(workspace.name) + const [reportRetentionDays, setReportRetentionDays] = useState(workspace.reportRetentionDays) const [newWorkspace, setNewWorkspace] = useState("") const [inviteEmail, setInviteEmail] = useState("") - const [inviteRole, setInviteRole] = useState<"admin" | "member" | "viewer">("member") const [invitationLink, setInvitationLink] = useState("") - const members = useQuery({ queryKey: ["workspace-members", workspace.id], queryFn: () => api<{ items: WorkspaceMember[] }>(`/workspaces/${workspace.id}/members`) }) - const invitations = useQuery({ queryKey: ["workspace-invitations", workspace.id], queryFn: () => api<{ items: WorkspaceInvitation[] }>(`/workspaces/${workspace.id}/invitations`) }) - const rename = useMutation({ mutationFn: () => api(`/workspaces/${workspace.id}`, { method: "PATCH", body: JSON.stringify({ name }) }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["session"] }) }) - const create = useMutation({ mutationFn: () => api("/workspaces", { method: "POST", body: JSON.stringify({ name: newWorkspace }) }), onSuccess: () => { setNewWorkspace(""); void queryClient.invalidateQueries({ queryKey: ["session"] }) } }) + const canManage = workspace.role === "owner" || workspace.role === "admin" + const members = usePagedQuery(["workspace-members", workspace.id], `/workspaces/${workspace.id}/members`) + const invitations = usePagedQuery(["workspace-invitations", workspace.id], `/workspaces/${workspace.id}/invitations`, { enabled: canManage }) + const updateWorkspace = useMutation({ + mutationFn: () => api(`/workspaces/${workspace.id}`, { method: "PATCH", body: JSON.stringify({ name, reportRetentionDays }) }), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["session"] }), + }) + const create = useMutation({ + mutationFn: () => api("/workspaces", { method: "POST", body: JSON.stringify({ name: newWorkspace }) }), + onSuccess: () => { setNewWorkspace(""); void queryClient.invalidateQueries({ queryKey: ["session"] }) }, + }) const invite = useMutation({ - mutationFn: () => api(`/workspaces/${workspace.id}/invitations`, { method: "POST", body: JSON.stringify({ email: inviteEmail, role: inviteRole }) }), + mutationFn: () => api(`/workspaces/${workspace.id}/invitations`, { method: "POST", body: JSON.stringify({ email: inviteEmail }) }), onSuccess: (created) => { const link = new URL("/app", window.location.origin) link.hash = new URLSearchParams({ invitation: created.token ?? "" }).toString() @@ -311,25 +1119,60 @@ function WorkspaceSettings({ workspace }: { workspace: Workspace }) { }) const revokeInvitation = useMutation({ mutationFn: (id: string) => api(`/workspaces/${workspace.id}/invitations/${id}`, { method: "DELETE" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["workspace-invitations", workspace.id] }) }) const removeMember = useMutation({ mutationFn: (id: string) => api(`/workspaces/${workspace.id}/members/${id}`, { method: "DELETE", body: "{}" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["workspace-members", workspace.id] }) }) - return
- + + return
+ } /> +
navigate("settings", undefined, tab)} ariaLabel={t("settings.tabs")} options={[{ value: "workspace", label: t("settings.workspaceTab") }, { value: "team", label: t("settings.teamTab") }, { value: "credentials", label: t("settings.credentialsTab") }]} />
+ {initialTab === "credentials" && } + {initialTab === "workspace" && <>
-

{t("settings.general")}

-
setName(event.target.value)} />
- {rename.isError && } + +
+ setName(event.target.value)} /> + setReportRetentionDays(Number(event.target.value))} /> + {canManage && } +
+

{t("settings.reportRetentionDescription")}

+ {updateWorkspace.isError && } +
+
+

{t("settings.createWorkspace")}

+

{t("settings.createWorkspaceDescription")}

+
setNewWorkspace(event.target.value)} />
+ {create.isError && }
- {(workspace.role === "owner" || workspace.role === "admin") &&
+ } + {initialTab === "team" && <> + {canManage ?

{t("settings.invitations")}

{t("settings.invitationsDescription")}

-
setInviteEmail(event.target.value)} placeholder="person@example.com" />
+
setInviteEmail(event.target.value)} placeholder="person@example.com" />
{invite.isError && } - {invitationLink &&

{t("settings.copyInvitation")}

} -
{invitations.isLoading ? : invitations.data?.items.map((invitation) =>

{invitation.email}

{invitation.acceptedAt ? t("settings.accepted") : invitation.revokedAt ? t("settings.revoked") : new Date(invitation.expiresAt) <= new Date() ? t("settings.expired") : t("settings.expires", { date: formatDateTime(invitation.expiresAt) })}

{formatRole(invitation.role)}{!invitation.acceptedAt && !invitation.revokedAt && }
)}
-
} + {invitationLink &&

{t("settings.copyInvitation")}

} +
+ {invitations.isLoading ? : invitations.isError ? void invitations.refetch()} /> : !invitations.data?.items.length ?

{t("settings.noInvitations")}

: invitations.data.items.map((invitation) =>
+

{invitation.email}

{invitation.acceptedAt ? t("settings.accepted") : invitation.revokedAt ? t("settings.revoked") : new Date(invitation.expiresAt) <= new Date() ? t("settings.expired") : t("settings.expires", { date: formatDateTime(invitation.expiresAt) })}

+ {!invitation.acceptedAt && !invitation.revokedAt && } +
)} + +
+ {revokeInvitation.isError && } +
: }

{t("settings.members")}

-
{members.isLoading ? : members.data?.items.map((member) => { const removable = member.userId !== workspace.createdBy && (workspace.role === "owner" || (workspace.role === "admin" && member.role !== "owner" && member.role !== "admin")); return
{(member.displayName || member.username || "U").slice(0, 1).toUpperCase()}

{member.displayName || member.username}

{member.email || member.userId}

{formatRole(member.role)}{removable && }
})}
+
+ {members.isLoading ? : members.isError ? void members.refetch()} /> : !members.data?.items.length ?

{t("settings.noMembers")}

: members.data.items.map((member) => { + const canRemove = canManage && member.role !== "owner" && member.userId !== userID && (workspace.role === "owner" || member.role === "member") + return
+
{(member.displayName || member.username || "U").slice(0, 1).toUpperCase()}
+

{member.displayName || member.username}

{member.email || member.userId}

+ {t(member.role === "owner" ? "settings.roleOwner" : member.role === "admin" ? "settings.roleAdmin" : "settings.roleMember")} + {canRemove && } +
})} + +
+ {removeMember.isError && }
-

{t("settings.createWorkspace")}

setNewWorkspace(event.target.value)} />
{create.isError && }
+ }
} diff --git a/frontend/src/app/Authentication.tsx b/frontend/src/app/Authentication.tsx index 4e08b0a..6d24f30 100644 --- a/frontend/src/app/Authentication.tsx +++ b/frontend/src/app/Authentication.tsx @@ -8,18 +8,23 @@ import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher" import { BrandMark, FormError } from "@/components/shared/presentation" import { useI18n } from "@/i18n/context" import { api, APIError } from "@/lib/api/client" +import { logout, oauthRedirectURL } from "@/lib/auth" import type { Session } from "@/lib/api/types" export function AuthenticationFailure({ error, invitationToken }: { error: unknown; invitationToken: string }) { const { formatError, t } = useI18n() const failure = error instanceof APIError ? error : null + const login = useMutation({ + mutationFn: oauthRedirectURL, + onSuccess: (redirectURL) => window.location.assign(redirectURL), + }) if (failure?.code === "account.suspended") { return } if (failure?.code === "registration.closed") { return } - if (failure?.status !== 401 || !failure.loginURL) { + if (failure?.status !== 401) { return } return ( @@ -30,7 +35,8 @@ export function AuthenticationFailure({ error, invitationToken }: { error: unkno ? t("auth.invitation.signInDescription") : t("auth.login.description")} > -

{t("auth.boundary")}

@@ -67,6 +73,10 @@ export function AccountRegistration({ queryClient.setQueryData(["session"], nextSession) }, }) + const signOut = useMutation({ + mutationFn: () => logout(session.auth.logoutUrl), + onSuccess: () => window.location.assign("/app"), + }) return ( {invited && }
} - + ) } diff --git a/frontend/src/app/AuthenticationContinuation.tsx b/frontend/src/app/AuthenticationContinuation.tsx new file mode 100644 index 0000000..d80d046 --- /dev/null +++ b/frontend/src/app/AuthenticationContinuation.tsx @@ -0,0 +1,14 @@ +import { useEffect } from "react" + +import { PageLoading } from "@/components/shared/presentation" +import { postLoginRedirectURL } from "@/lib/auth" + +export function AuthenticationContinuation() { + useEffect(() => { + const redirectURI = new URLSearchParams(window.location.search).get("redirect_uri") + + window.location.replace(postLoginRedirectURL(redirectURI)) + }, []) + + return +} diff --git a/frontend/src/components/shared/presentation.tsx b/frontend/src/components/shared/presentation.tsx index c8d846c..72408c4 100644 --- a/frontend/src/components/shared/presentation.tsx +++ b/frontend/src/components/shared/presentation.tsx @@ -1,12 +1,13 @@ +import { useEffect, useRef } from "react" import type { LucideIcon } from "lucide-react" -import { LoaderCircle } from "lucide-react" +import { AlertTriangle, LoaderCircle, X } from "lucide-react" import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher" import { cn } from "@/lib/utils" import { useI18n } from "@/i18n/context" export function PageTitle({ title, description, action }: { title: string; description: string; action?: React.ReactNode }) { - return

{title}

{description}

{action}
+ return

{title}

{description}

{action &&
{action}
}
} export function Field({ label, wide = false, children }: { label: string; wide?: boolean; children: React.ReactNode }) { @@ -15,15 +16,114 @@ export function Field({ label, wide = false, children }: { label: string; wide?: export function StateBadge({ state }: { state: string }) { const { formatState } = useI18n() - return {formatState(state)} + const tone = state === "ready" || state === "connected" || state === "succeeded" + ? "bg-emerald-50 text-emerald-700" + : state === "failed" || state === "error" || state === "invalid" + ? "bg-red-50 text-red-700" + : state === "archived" || state === "disabled" || state === "cancelled" || state === "revoked" + ? "bg-slate-100 text-slate-600" + : "bg-blue-50 text-blue-700" + return {formatState(state)} } -export function Metric({ label, value }: { label: string; value: string }) { - return

{label}

{value}

+export function Metric({ label, value, className }: { label: string; value: string; className?: string }) { + return

{label}

{value}

} -export function EmptyState({ icon: Icon, title, description }: { icon: LucideIcon; title: string; description: string }) { - return

{title}

{description}

+export function EmptyState({ icon: Icon, title, description, action }: { icon: LucideIcon; title: string; description: string; action?: React.ReactNode }) { + return

{title}

{description}

{action &&
{action}
}
+} + +export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) { + const { formatError, t } = useI18n() + return

{t("common.requestFailed")}

{formatError(error)}

{onRetry && }
+} + +export function Panel({ className, children }: { className?: string; children: React.ReactNode }) { + return
{children}
+} + +export function SectionHeader({ title, description, action, className }: { title: string; description?: string; action?: React.ReactNode; className?: string }) { + return

{title}

{description &&

{description}

}
{action &&
{action}
}
+} + +export function SegmentedControl({ value, options, onChange, ariaLabel }: { value: T; options: Array<{ value: T; label: string; count?: number }>; onChange: (value: T) => void; ariaLabel: string }) { + return
+ {options.map((option) => )} +
+} + +export function DefinitionItem({ label, value, mono = false, className }: { label: string; value: React.ReactNode; mono?: boolean; className?: string }) { + return
{label}
{value}
+} + +export function Drawer({ open, title, description, onClose, children, footer }: { open: boolean; title: string; description?: string; onClose: () => void; children: React.ReactNode; footer?: React.ReactNode }) { + const { t } = useI18n() + const dialogRef = useRef(null) + const onCloseRef = useRef(onClose) + + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + useEffect(() => { + if (!open) return + + const previousOverflow = document.body.style.overflow + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null + const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + const focusDialog = window.requestAnimationFrame(() => { + const firstFocusable = dialogRef.current?.querySelector(focusableSelector) + const focusTarget = firstFocusable ?? dialogRef.current + focusTarget?.focus() + }) + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onCloseRef.current() + return + } + if (event.key !== "Tab" || !dialogRef.current) return + + const focusable = Array.from(dialogRef.current.querySelectorAll(focusableSelector)) + .filter((element) => element.getClientRects().length > 0) + if (!focusable.length) { + event.preventDefault() + dialogRef.current.focus() + return + } + + const first = focusable[0] + const last = focusable[focusable.length - 1] + const active = document.activeElement + if (event.shiftKey && (active === first || !dialogRef.current.contains(active))) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && active === last) { + event.preventDefault() + first.focus() + } + } + document.body.style.overflow = "hidden" + window.addEventListener("keydown", handleKeyDown) + + return () => { + window.cancelAnimationFrame(focusDialog) + document.body.style.overflow = previousOverflow + window.removeEventListener("keydown", handleKeyDown) + previousFocus?.focus() + } + }, [open]) + + if (!open) return null + + return
+ +
{children}
+ {footer &&
{footer}
} + +
} export function FormError({ message }: { message: string }) { diff --git a/frontend/src/i18n/I18nProvider.tsx b/frontend/src/i18n/I18nProvider.tsx index 770b536..0927bde 100644 --- a/frontend/src/i18n/I18nProvider.tsx +++ b/frontend/src/i18n/I18nProvider.tsx @@ -6,7 +6,7 @@ import type { TranslationKey } from "@/i18n/catalog" import { I18nContext } from "@/i18n/context" import type { I18nContextValue } from "@/i18n/context" import { syncDocumentLocale } from "@/i18n/document" -import { formatDateTime as formatLocalizedDateTime, formatError as formatLocalizedError, formatRole as formatLocalizedRole, formatState as formatLocalizedState, translate } from "@/i18n/format" +import { formatDateTime as formatLocalizedDateTime, formatError as formatLocalizedError, formatState as formatLocalizedState, translate } from "@/i18n/format" import type { TranslationParams } from "@/i18n/format" import { detectLocale, persistLocale } from "@/i18n/locale" import type { Locale } from "@/i18n/locale" @@ -32,10 +32,6 @@ export function I18nProvider({ children }: { children: ReactNode }) { return formatLocalizedState(catalog, state) }, [catalog]) - const formatRole = useCallback((role: string) => { - return formatLocalizedRole(catalog, role) - }, [catalog]) - const formatError = useCallback((error: unknown) => { return formatLocalizedError(catalog, error) }, [catalog]) @@ -50,9 +46,8 @@ export function I18nProvider({ children }: { children: ReactNode }) { t, formatDateTime, formatState, - formatRole, formatError, - }), [formatDateTime, formatError, formatRole, formatState, locale, setLocale, t]) + }), [formatDateTime, formatError, formatState, locale, setLocale, t]) return {children} } diff --git a/frontend/src/i18n/catalog.ts b/frontend/src/i18n/catalog.ts index 9b97bf5..58864c6 100644 --- a/frontend/src/i18n/catalog.ts +++ b/frontend/src/i18n/catalog.ts @@ -10,17 +10,24 @@ export const en = { "common.provider": "Provider", "common.platform": "Platform", "common.cancel": "Cancel", + "common.retry": "Retry", + "common.archive": "Archive", + "common.restore": "Restore", "common.save": "Save", "common.test": "Test", "common.copy": "Copy", + "common.copied": "Copied", + "common.copyFailed": "Copy failed", "common.delete": "Delete", "common.close": "Close", "common.enabled": "Enabled", "common.disabled": "Disabled", "common.loading": "Loading", + "common.never": "Never", "common.requestFailed": "Request failed", "common.viewAll": "View all", "common.loadOlder": "Load older messages", + "common.loadMore": "Load more", "common.signOut": "Sign out", "common.workspace": "Workspace", @@ -42,10 +49,10 @@ export const en = { "landing.capability.assessment.description": "Continuously surface quality risks so AI-generated code meets the same engineering standards as any production code.", "landing.capability.messaging.title": "Built into team communication", "landing.capability.messaging.description": "Work with MoonCode through Feishu, DingTalk, and the tools your team already knows, bringing quality feedback into daily collaboration.", - "landing.capability.saas.title": "Multi-tenant SaaS", - "landing.capability.saas.description": "Keep organizations, members, and data isolated by Workspace with explicit identity and role authorization boundaries.", + "landing.capability.saas.title": "Workspace collaboration", + "landing.capability.saas.description": "Organize repositories, members, analyses, and team channels within clear Workspace data boundaries.", "landing.capability.source.title": "Connect your source", - "landing.capability.source.description": "Sync from GitHub, GitLab, and generic Git services, pinning every assessment to an exact commit.", + "landing.capability.source.description": "Sync from GitHub and GitLab, pinning every assessment to an exact commit.", "landing.workflow.eyebrow": "How it works", "landing.workflow.title": "From one commit to continuous improvement.", "landing.workflow.description": "Make code quality an everyday engineering signal instead of a one-time check before release.", @@ -58,8 +65,8 @@ export const en = { "landing.foundation.eyebrow": "Built for teams", "landing.foundation.title": "Connect existing tools. Protect every Workspace boundary.", "landing.foundation.auth": "GitHub OAuth login with authentication handled by tinyauth", - "landing.foundation.authorization": "Workspace data isolation with Owner, Admin, Member, and Viewer roles", - "landing.foundation.secrets": "Encrypted Git and IM credentials that are never returned by the API", + "landing.foundation.authorization": "Workspace membership boundaries keep collaborative resources isolated", + "landing.foundation.secrets": "Personal SCM credentials and Workspace IM credentials are encrypted and never returned by the API", "landing.cta.title": "Let code quality keep pace with AI.", "landing.cta.description": "Start with your first repository and build a continuous, transparent quality feedback loop for your team.", "landing.cta.action": "Open MoonCode", @@ -82,7 +89,7 @@ export const en = { "landing.preview.caption": "Product interface concept · Assessment capabilities will evolve with the platform", "auth.accountSuspended.title": "Account suspended", - "auth.accountSuspended.description": "Your MoonCode account is currently suspended. Contact the workspace administrator if you believe this is a mistake.", + "auth.accountSuspended.description": "Your MoonCode account is currently suspended. Contact the deployment operator if you believe this is a mistake.", "auth.registrationClosed.title": "Registration is closed", "auth.registrationClosed.description": "This MoonCode deployment is not accepting new accounts.", "auth.unavailable.title": "MoonCode is unavailable", @@ -101,7 +108,7 @@ export const en = { "auth.setup.createTitle": "Create your MoonCode account", "auth.invitation.requiredTitle": "An invitation is required", "auth.invitation.signedIn": "Signed in as {email}. Confirm the policies to join the workspace.", - "auth.setup.welcome": "Welcome, {name}. Confirm the policies to create your personal workspace.", + "auth.setup.welcome": "Welcome, {name}. Confirm the policies to activate your account.", "auth.invitation.requiredDescription": "You are authenticated, but this deployment only activates accounts through a workspace invitation.", "auth.acceptTerms": "I accept Terms {version}", "auth.acceptPrivacy": "I accept Privacy Policy {version}", @@ -111,9 +118,12 @@ export const en = { "nav.toggle": "Toggle navigation", "nav.workspace": "Workspace", + "nav.account": "Account settings", + "nav.qualityWorkflow": "Quality workflow", + "nav.teamCollaboration": "Team collaboration", "nav.overview": "Overview", "nav.repositories": "Repositories", - "nav.connections": "Connections", + "nav.analysis": "Quality analysis", "nav.channels": "Channels", "nav.messages": "Messages", "nav.settings": "Settings", @@ -127,58 +137,159 @@ export const en = { "overview.heroTitle": "Build a reliable foundation for AI code quality.", "overview.heroDescription": "Connect a Git repository and Feishu or DingTalk. MoonCode pins assessments to exact commits and brings your team into one quality workflow.", "overview.repositories": "Repositories", + "overview.withoutCode": "Without synchronized code", + "overview.failedSyncs": "Recent failed syncs", + "overview.activeAnalyses": "Active analyses", "overview.activeChannels": "Active channels", "overview.recentMessages": "Recent messages", "overview.failedJobs": "Failed jobs", "overview.recentRepositories": "Recent repositories", "overview.noMessages": "No messages", "overview.noMessagesDescription": "Connected channels will publish inbound messages here.", + "overview.attentionTitle": "Needs attention", + "overview.attentionDescription": "{count} items need your attention.", + "overview.allClear": "Your quality workflow is healthy.", + "overview.failedSyncAction": "Review failed repository synchronization attempts.", + "overview.unsyncedAction": "Synchronize repositories that do not have a commit snapshot.", + "overview.noAttention": "Everything is ready. No repository or workflow issues need attention.", + "overview.setupTitle": "Workspace setup", + "overview.setupProgress": "{completed} of {total} essential steps complete", + "overview.setupRepository": "Add your first repository", + "overview.setupSync": "Synchronize a commit snapshot", + "overview.setupChannel": "Connect a team message channel", + "overview.synchronized": "Synchronized", "repositories.title": "Repositories", "repositories.description": "Keep one explicitly synchronized commit for each repository.", "repositories.add": "Add repository", - "repositories.cloneURL": "Clone URL", - "repositories.refOptional": "Branch, tag, or commit (optional)", - "repositories.genericHTTPS": "Generic Git HTTPS", - "repositories.connectAndSync": "Connect and sync", + "repositories.addAndSync": "Add and sync", "repositories.empty": "No repositories", "repositories.emptyDescription": "Connect a repository to synchronize its current source.", "repositories.cancelSync": "Cancel synchronization", "repositories.sync": "Synchronize", "repositories.delete": "Delete repository", "repositories.deleteConfirm": "Delete {name} and its local source?", - "repositories.syncRefPrompt": "Branch, tag, or commit to synchronize. Leave empty to use the remote default.", - "repositories.remoteDefault": "remote default", "repositories.notSynced": "not synchronized", + "repositories.remoteUrl": "Repository URL", + "repositories.rename": "Rename repository", + "repositories.ref": "Branch, tag, or commit (ref)", + "repositories.nameOptional": "Name (optional)", + "repositories.namePlaceholder": "Derived from repository URL when empty", + "repositories.credentialOptional": "Personal PAT (optional)", + "repositories.publicAccess": "No credential (public repository)", + "repositories.chooseRef": "A branch and tag share this name. Choose which ref to track.", + "repositories.syncHistory": "Synchronization history", + "repositories.targetExample": "main / v1.0.0 / commit SHA", + "repositories.changeTarget": "Change target", + "repositories.saveTarget": "Save and sync", + "repositories.searchPlaceholder": "Search repositories", + "repositories.noSearchResults": "No repositories match your search.", + "repositories.createDescription": "Enter a Git repository and ref. MoonCode will create its managed mirror and synchronize the current commit.", + "repositories.analyze": "Quality analysis", + "repositories.currentCommit": "Current commit", + "repositories.trackedRef": "Tracked ref", + "repositories.lastSync": "Last synchronized", + "repositories.mirrorSize": "Mirror size", + "repositories.detailTabs": "Repository detail sections", + "repositories.activity": "Activity", + "repositories.syncHistoryDescription": "Repository synchronization and source update activity.", + "repositories.noActivity": "No repository activity", + "repositories.noActivityDescription": "Synchronization operations will appear here.", + "repositories.generalSettings": "General settings", + "repositories.generalSettingsDescription": "Change the display name used within this workspace.", + "repositories.sourceSettings": "Repository source", + "repositories.sourceSettingsDescription": "Changing the URL or ref creates and synchronizes a new current commit snapshot.", + "repositories.dangerZone": "Danger zone", + "repositories.dangerZoneDescription": "Archive this repository or permanently remove its managed mirror and history.", + "repositories.copyCommit": "Copy commit SHA", + "repositories.operationProvision": "Initial synchronization", + "repositories.operationRefresh": "Synchronize ref", + "repositories.operationUpdate": "Update repository source", + "repositories.operationPurge": "Remove repository data", + "repositories.outcomeChanged": "new commit", + "repositories.outcomeNoChange": "already current", - "connections.title": "Connections", - "connections.description": "Credentials are encrypted and are never returned by the API.", - "connections.new": "New connection", - "connections.baseURL": "Base URL", - "connections.username": "Username", - "connections.accessToken": "Access token", - "connections.genericGit": "Generic Git", - "connections.save": "Save connection", - "connections.empty": "No connections", - "connections.emptyDescription": "Add credentials for private Git repositories.", - "connections.credentialConfigured": "Credential configured", - "connections.publicAccess": "Public access", - "connections.delete": "Delete connection", - "connections.deleteConfirm": "Delete {name}?", - "connections.displayName": "Display name", - "connections.replaceToken": "Replace access token", - "connections.keepToken": "Leave blank to keep current", - "connections.repositoryURL": "Repository URL", - "connections.succeeded": "Connection succeeded.", + "providerConnections.title": "Provider connections", + "providerConnections.description": "Manage your private GitHub and GitLab PATs. A connection is only available to your user account.", + "providerConnections.add": "Add provider connection", + "providerConnections.baseUrl": "Base URL", + "providerConnections.token": "Personal access token", + "providerConnections.privateHint": "Private to your user account and never shared with a workspace", + "providerConnections.default": "default", + "providerConnections.makeDefault": "Make default", + "providerConnections.rotate": "Replace PAT", + "providerConnections.newToken": "New personal access token", + "providerConnections.rotateSave": "Validate and replace", + "providerConnections.deleteConfirm": "Delete provider connection {name}?", + "providerConnections.testSucceeded": "Connection verified successfully.", + + "analysis.run": "Analyze snapshot", + "analysis.snapshot": "Commit snapshot", + "analysis.profile": "Analysis profile", + "analysis.profileOption": "{name} · v{version}", + "analysis.profiles": "Analysis profiles", + "analysis.profilesDescription": "Versioned analyzer settings frozen into every run.", + "analysis.addProfile": "Add profile", + "analysis.timeoutSeconds": "Timeout (seconds)", + "analysis.saveProfile": "Save profile", + "analysis.createVersion": "Create version", + "analysis.profileMetadata": "v{version} · {timeout}s timeout · scc", + "analysis.title": "Quality analysis", + "analysis.description": "Analyze exact repository snapshots across this workspace.", + "analysis.empty": "No repositories to analyze", + "analysis.emptyDescription": "Add and synchronize a repository before running quality analysis.", + "analysis.noSnapshot": "Synchronize this repository before running analysis.", + "analysis.codeScale": "Code scale", + "analysis.none": "No analysis has been run for this snapshot.", + "analysis.summary": "{files} files · {code} code lines · {languages} languages", + "analysis.manageProfiles": "Manage profiles", + "analysis.chooseRepository": "Choose repository", + "analysis.stepSnapshot": "1. Commit snapshot", + "analysis.stepProfile": "2. Analysis profile", + "analysis.noSnapshotTitle": "No commit snapshot", + "analysis.noProfilesTitle": "No analysis profile", + "analysis.noProfilesDescription": "Create a profile before running the repository analysis.", + "analysis.metricFiles": "Files", + "analysis.metricCode": "Lines of code", + "analysis.metricLanguages": "Languages", + "analysis.metricComplexity": "Complexity", + "analysis.languageDistribution": "Language distribution", + "analysis.reportMetadata": "Commit {commit} · completed in {duration} ms", + "analysis.languageFiles": "{count} files", + "analysis.noReportTitle": "Ready to analyze", + "analysis.runHistory": "Run history", + "analysis.reportTitle": "Analysis report", + "analysis.reportSelection": "Commit {commit} · profile {profile}", + "analysis.backToSelection": "Back to current selection", + "analysis.runStatus": "Analysis in progress", + "analysis.runStatusDescription": "Stage: {stage} · attempt {attempt}", + "analysis.runHistoryDescription": "Open any previous run to inspect its status or report.", + "analysis.attempt": "attempt {attempt}", + "analysis.viewReport": "View report", + "analysis.metricLines": "Total lines", + "analysis.metricComments": "Comment lines", + "analysis.metricBlanks": "Blank lines", + "analysis.metricBytes": "Source size", + "analysis.metricDuration": "Duration", + "analysis.metricAnalyzer": "Analyzer", + "analysis.languageShare": "{code} · {percentage}%", + "analysis.warnings": "Analyzer warnings", + "analysis.warningsDescription": "The analyzer completed, but reported the following conditions.", "channels.title": "Channels", "channels.description": "Receive team messages through managed long-lived connections.", "channels.add": "Add channel", + "channelLink.linking": "Linking your chat identity…", + "channelLink.success": "Your chat identity is now linked to this MoonCode user.", "channels.engineeringBot": "Engineering bot", "channels.appID": "App ID", "channels.clientID": "Client ID", "channels.appSecret": "App secret", "channels.clientSecret": "Client secret", + "channels.robotCode": "Robot code", + "channels.chatID": "Target chat ID", + "channels.conversationID": "Open conversation ID", + "channels.useLark": "Use Lark international endpoints", "channels.allowedSenders": "Allowed sender IDs", "channels.allowedSendersHelp": "Comma-separated; use * only when every sender is trusted.", "channels.groupPrefix": "Group prefix", @@ -194,6 +305,21 @@ export const en = { "channels.deleteConfirm": "Delete {name}?", "channels.requireMentionShort": "Require mention or prefix", "channels.saveChanges": "Save changes", + "channels.replacementSecret": "Enter a new secret", + "channels.rotateCredential": "Replace credential", + "channels.notifications": "Analysis notifications", + "channels.notifyStarted": "Started", + "channels.notifySucceeded": "Succeeded", + "channels.notifyFailed": "Failed", + "channels.createDescription": "Connect a Feishu, Lark, or DingTalk application for inbound messages and analysis notifications.", + "channels.connectionStatus": "Connection", + "channels.lastConnected": "Last connected", + "channels.credential": "Credential", + "channels.configured": "Configured", + "channels.receivingMessages": "Receiving messages", + "channels.notReceivingMessages": "Message reception is paused", + "channels.configureTitle": "Configure {name}", + "channels.configureDescription": "Update message access, notifications, and channel credentials.", "messages.title": "Messages", "messages.description": "Normalized inbound messages received from connected channels.", @@ -204,17 +330,27 @@ export const en = { "messages.empty": "No messages", "messages.emptyDescription": "Incoming Feishu and DingTalk messages will appear here.", "messages.via": "via {channel}", + "messages.channels": "Channels", + "messages.conversations": "Conversations", + "messages.noChannels": "No channels configured.", + "messages.noConversations": "No conversations yet.", + "messages.allMessages": "All messages", + "messages.conversationVia": "Conversation through {channel}", + "messages.streamDescription": "Messages across the selected channels and conversations.", - "settings.title": "Workspace settings", - "settings.description": "Manage the workspace and invite people by verified GitHub email.", + "settings.title": "Settings", + "settings.description": "Manage your personal provider connections and this workspace.", "settings.general": "General", "settings.workspaceName": "Workspace name", + "settings.reportRetentionDays": "Analysis retention (days)", + "settings.reportRetentionDescription": "Completed analysis runs and reports are removed after this period. Active runs are never removed.", "settings.invitations": "Invitations", "settings.invitationsDescription": "The recipient must sign in with the verified GitHub email entered here.", "settings.email": "Email", - "settings.role": "Role", "settings.createInvitation": "Create invitation", "settings.copyInvitation": "Copy this link now. The token is only returned once.", + "settings.noInvitations": "No pending or historical invitations.", + "settings.noMembers": "No workspace members found.", "settings.accepted": "Accepted", "settings.revoked": "Revoked", "settings.expired": "Expired", @@ -225,11 +361,19 @@ export const en = { "settings.createWorkspace": "Create another workspace", "settings.create": "Create", "settings.emptyModule": "{title} is ready for the next product slice.", + "settings.tabs": "Settings sections", + "settings.workspaceTab": "Workspace", + "settings.teamTab": "Team members & invitations", + "settings.credentialsTab": "Personal code credentials", + "settings.createWorkspaceDescription": "Create a separate workspace with its own repositories, members, and channels.", + "settings.workspaceManageDescription": "Manage this workspace's identity and analysis report retention.", + "settings.workspaceReadOnlyDescription": "Only workspace owners and administrators can change these settings.", + "settings.adminOnlyDescription": "Only workspace owners and administrators can create or revoke invitations.", + "settings.roleOwner": "Owner", + "settings.roleAdmin": "Administrator", + "settings.roleMember": "Member", + "settings.removeMemberConfirm": "Remove {name} from this workspace?", - "role.owner": "Owner", - "role.admin": "Admin", - "role.member": "Member", - "role.viewer": "Viewer", "state.pending": "Pending", "state.queued": "Queued", "state.active": "Active", @@ -241,6 +385,7 @@ export const en = { "state.running": "Running", "state.connected": "Connected", "state.disabled": "Disabled", + "state.error": "Error", "state.uploading": "Uploading", "state.deleting": "Deleting", "state.cancelled": "Cancelled", @@ -248,6 +393,9 @@ export const en = { "state.retry_wait": "Waiting to retry", "state.suspended": "Suspended", "state.deleted": "Deleted", + "state.owner": "Owner", + "state.admin": "Administrator", + "state.member": "Member", "error.http.request_failed": "Request failed.", "error.request.invalid_json": "The request body is invalid.", @@ -280,8 +428,26 @@ export const en = { "error.workspace.list_failed": "Unable to list workspaces.", "error.workspace.create_failed": "Unable to create the workspace.", "error.workspace.update_failed": "Unable to update the workspace.", + "error.workspace.update_required": "Change at least one workspace setting.", + "error.workspace.name_required": "The workspace name is required.", + "error.workspace.report_retention_invalid": "Analysis retention must be between 1 and 3650 days.", "error.workspace.members_failed": "Unable to load workspace members.", "error.workspace.member_failed": "Unable to update the workspace member.", + "error.workspace.last_member": "A workspace must keep at least one member.", + "error.workspace.forbidden": "You do not have permission to access this workspace.", + "error.workspace.invitation_email_invalid": "Enter a valid invitation email address.", + "error.workspace.invitation_not_found": "The workspace invitation was not found.", + "error.workspace.invitation_recipient_mismatch": "This invitation belongs to a different account.", + "error.workspace.invitation_unavailable": "The workspace invitation is no longer available.", + "error.workspace.member_not_removable": "This workspace member cannot be removed.", + "error.account.registration_closed": "Registration is not currently open.", + "error.account.terms_required": "Accept the terms and privacy policy to continue.", + "error.auth.identity_failed": "Unable to verify your authenticated identity.", + "error.auth.required": "Sign in to continue.", + "error.request.body_invalid": "The request body is invalid.", + "error.request.body_too_large": "The request body is too large.", + "error.request.page_invalid": "The requested page is invalid.", + "error.request.parameter_invalid": "A request parameter is invalid.", "error.user.invalid_id": "The user ID is invalid.", "error.invitation.invalid_id": "The invitation ID is invalid.", "error.invitation.accept_failed": "Unable to accept the invitation.", @@ -295,16 +461,24 @@ export const en = { "error.channel.delete_failed": "Unable to delete the channel.", "error.channel.state_failed": "Unable to change the channel state.", "error.channel.status_failed": "Unable to load the channel status.", + "error.channel.subscription_event_invalid": "The selected analysis notification event is invalid.", + "error.channel.allowlist_invalid": "The channel sender allowlist is invalid.", + "error.channel.allowlist_required": "Configure at least one allowed sender.", + "error.channel.configuration_stale": "The channel configuration changed; reload and try again.", + "error.channel.credential_missing": "The channel credential is missing.", + "error.channel.credential_required": "Enter the channel credential.", + "error.channel.credentials_invalid": "The channel credentials are invalid.", + "error.channel.group_prefix_invalid": "The channel group prefix is invalid.", + "error.channel.identity_link_invalid": "The channel identity link is invalid or expired.", + "error.channel.name_required": "The channel name is required.", + "error.channel.not_found": "The channel was not found.", + "error.channel.type_invalid": "The channel type is invalid.", + "error.channel.value_required": "A required channel setting is missing.", + "error.channel.values_invalid": "The channel settings are invalid.", "error.conversation.invalid_id": "The conversation ID is invalid.", "error.conversation.list_failed": "Unable to load conversations.", "error.message.list_failed": "Unable to load messages.", "error.overview.get_failed": "Unable to load the overview.", - "error.connection.invalid_id": "The connection ID is invalid.", - "error.connection.create_failed": "Unable to create the connection.", - "error.connection.list_failed": "Unable to load connections.", - "error.connection.update_failed": "Unable to update the connection.", - "error.connection.delete_failed": "Unable to delete the connection.", - "error.connection.test_failed": "Unable to verify the connection.", "error.repository.invalid_id": "The repository ID is invalid.", "error.repository.create_failed": "Unable to create the repository.", "error.repository.list_failed": "Unable to load repositories.", @@ -313,6 +487,50 @@ export const en = { "error.repository.delete_failed": "Unable to delete the repository.", "error.repository.sync_failed": "Unable to synchronize the repository.", "error.repository.cancel_failed": "Unable to cancel synchronization.", + "error.repository.preview_failed": "Unable to access the repository or resolve the ref.", + "error.repository.rename_failed": "Unable to rename the repository.", + "error.repository.target_failed": "Unable to update the repository target.", + "error.repository.archive_failed": "Unable to change the repository archive state.", + "error.repository.workspace_quota_exceeded": "This workspace has reached its repository limit.", + "error.repository.deletion_conflict": "The repository could not be deleted in its current state.", + "error.repository.deletion_unavailable": "Wait for the active repository operation to finish or cancel it before deleting.", + "error.repository.operation_not_cancellable": "The repository operation can no longer be cancelled.", + "error.repository.ref_required": "Enter a branch, tag, ref, or commit SHA.", + "error.repository.refresh_unavailable": "The repository cannot be refreshed in its current state.", + "error.repository.update_unavailable": "The repository source cannot be changed in its current state.", + "error.repository.remote_invalid": "The repository URL is invalid or unsupported.", + "error.repository.source_conflict": "This repository source is already managed by the workspace.", + "error.repository.state_conflict": "The repository changed while the operation was being requested. Refresh and try again.", + "error.analysis.invalid_id": "The analysis run ID is invalid.", + "error.analysis.list_failed": "Unable to load analysis history.", + "error.analysis.get_failed": "Unable to load the analysis run.", + "error.analysis.create_failed": "Unable to start the analysis.", + "error.analysis.cancel_failed": "Unable to cancel the analysis run.", + "error.analysis.retry_failed": "Unable to retry the analysis run.", + "error.analysis.repository_unavailable": "The repository is not available for analysis.", + "error.analysis.snapshot_required": "Synchronize the repository before starting analysis.", + "error.analysis.snapshot_unavailable": "The selected commit snapshot is no longer available.", + "error.analysis.profile_definition_invalid": "The analysis profile definition is invalid.", + "error.analysis.profile_definition_unsupported": "The analysis profile uses unsupported settings.", + "error.analysis.profile_timeout_invalid": "The profile timeout must be between 1 and 86400 seconds.", + "error.analysis.profile_name_required": "The analysis profile name is required.", + "error.analysis.profile_version_invalid": "The analysis profile version is invalid.", + "error.analysis.profile_version_stale": "The analysis profile changed; reload and try again.", + "error.analysis.profile_not_found": "The analysis profile was not found.", + "error.analysis.profile_required": "Create an analysis profile before starting analysis.", + "error.analysis.profile_last_active": "Keep at least one active analysis profile.", + "error.analysis.profile_snapshot_invalid": "The frozen analysis profile is invalid.", + "error.analysis.workspace_concurrency_exceeded": "This workspace has reached its concurrent analysis limit.", + "error.analysis.not_cancellable": "The analysis run can no longer be cancelled.", + "error.analysis.not_retryable": "The analysis run cannot be retried.", + "error.provider.connection_not_found": "The provider connection was not found.", + "error.provider.connection_mismatch": "The selected PAT does not match this repository host.", + "error.provider.connection_inactive": "The selected provider connection is not active.", + "error.provider.connection_unavailable": "The provider connection is unavailable.", + "error.provider.credential_invalid": "The personal access token is invalid.", + "error.provider.credential_stale": "The personal access token changed; retry with the current credential.", + "error.provider.token_required": "Enter a personal access token.", + "error.provider.type_invalid": "The provider type is invalid.", "error.job.invalid_id": "The job ID is invalid.", "error.job.get_failed": "Unable to load the job.", "error.job.cancel_failed": "Unable to cancel the job.", @@ -335,17 +553,24 @@ export const zhCN: Catalog = { "common.provider": "服务商", "common.platform": "平台", "common.cancel": "取消", + "common.retry": "重试", + "common.archive": "归档", + "common.restore": "恢复", "common.save": "保存", "common.test": "测试", "common.copy": "复制", + "common.copied": "已复制", + "common.copyFailed": "复制失败", "common.delete": "删除", "common.close": "关闭", "common.enabled": "已启用", "common.disabled": "已停用", "common.loading": "加载中", + "common.never": "从未", "common.requestFailed": "请求失败", "common.viewAll": "查看全部", "common.loadOlder": "加载更早消息", + "common.loadMore": "加载更多", "common.signOut": "退出登录", "common.workspace": "工作空间", @@ -367,10 +592,10 @@ export const zhCN: Catalog = { "landing.capability.assessment.description": "持续发现代码中的质量风险,让 AI 生成的代码同样经得起工程标准检验。", "landing.capability.messaging.title": "融入团队沟通", "landing.capability.messaging.description": "通过飞书、钉钉等团队熟悉的入口与 MoonCode 交互,让质量反馈自然进入协作流程。", - "landing.capability.saas.title": "多租户 SaaS", - "landing.capability.saas.description": "以 Workspace 隔离组织、成员和数据,提供清晰的身份认证与角色授权边界。", + "landing.capability.saas.title": "Workspace 协作", + "landing.capability.saas.description": "在清晰的 Workspace 数据边界内组织代码仓库、成员、分析任务和团队消息渠道。", "landing.capability.source.title": "连接你的代码", - "landing.capability.source.description": "从 GitHub、GitLab 和通用 Git 服务同步代码,让每一次评估都精确对应一个 commit。", + "landing.capability.source.description": "从 GitHub 和 GitLab 同步代码,让每一次评估都精确对应一个 commit。", "landing.workflow.eyebrow": "工作方式", "landing.workflow.title": "从一次提交,到持续改进。", "landing.workflow.description": "让代码质量成为研发过程中的日常信号,而不是发布前才进行的一次性检查。", @@ -383,8 +608,8 @@ export const zhCN: Catalog = { "landing.foundation.eyebrow": "为团队而生", "landing.foundation.title": "连接现有工具,守住每个 Workspace 的边界。", "landing.foundation.auth": "GitHub OAuth 登录,认证流程交由 tinyauth 处理", - "landing.foundation.authorization": "Workspace 级数据隔离与 Owner、Admin、Member、Viewer 授权", - "landing.foundation.secrets": "Git 与 IM 凭据加密保存,敏感信息不通过 API 回显", + "landing.foundation.authorization": "通过 Workspace 成员边界隔离团队协作资源", + "landing.foundation.secrets": "个人 SCM 凭据与 Workspace 消息渠道凭据均加密保存,且不通过 API 回显", "landing.cta.title": "让高质量代码,跟上 AI 的速度。", "landing.cta.description": "从连接第一个代码仓库开始,为团队建立持续、清晰的代码质量反馈。", "landing.cta.action": "进入 MoonCode", @@ -407,7 +632,7 @@ export const zhCN: Catalog = { "landing.preview.caption": "产品界面示意 · 评估能力将随平台演进持续完善", "auth.accountSuspended.title": "账户已暂停", - "auth.accountSuspended.description": "你的 MoonCode 账户当前已暂停。如有疑问,请联系工作空间管理员。", + "auth.accountSuspended.description": "你的 MoonCode 账户当前已暂停。如有疑问,请联系部署运维人员。", "auth.registrationClosed.title": "注册已关闭", "auth.registrationClosed.description": "当前 MoonCode 部署暂不接受新账户。", "auth.unavailable.title": "MoonCode 暂不可用", @@ -426,7 +651,7 @@ export const zhCN: Catalog = { "auth.setup.createTitle": "创建 MoonCode 账户", "auth.invitation.requiredTitle": "需要工作空间邀请", "auth.invitation.signedIn": "当前以 {email} 登录。确认相关政策后即可加入工作空间。", - "auth.setup.welcome": "欢迎,{name}。确认相关政策后即可创建个人工作空间。", + "auth.setup.welcome": "欢迎,{name}。确认相关政策后即可激活账户。", "auth.invitation.requiredDescription": "身份认证已完成,但当前部署只允许通过工作空间邀请激活账户。", "auth.acceptTerms": "我接受服务条款 {version}", "auth.acceptPrivacy": "我接受隐私政策 {version}", @@ -436,9 +661,12 @@ export const zhCN: Catalog = { "nav.toggle": "切换导航", "nav.workspace": "工作空间", + "nav.account": "账户设置", + "nav.qualityWorkflow": "质量工作流", + "nav.teamCollaboration": "团队协作", "nav.overview": "概览", "nav.repositories": "代码仓库", - "nav.connections": "代码连接", + "nav.analysis": "质量分析", "nav.channels": "消息渠道", "nav.messages": "消息", "nav.settings": "设置", @@ -452,58 +680,159 @@ export const zhCN: Catalog = { "overview.heroTitle": "为 AI 代码质量建立可靠基础", "overview.heroDescription": "连接 Git 代码仓库以及飞书或钉钉。MoonCode 让评估精确对应 commit,并把团队带入同一套质量流程。", "overview.repositories": "代码仓库", + "overview.withoutCode": "尚无同步代码", + "overview.failedSyncs": "近期同步失败", + "overview.activeAnalyses": "运行中的分析", "overview.activeChannels": "活跃渠道", "overview.recentMessages": "最近消息", "overview.failedJobs": "失败任务", "overview.recentRepositories": "最近仓库", "overview.noMessages": "暂无消息", "overview.noMessagesDescription": "已连接渠道收到的消息会显示在这里。", + "overview.attentionTitle": "待处理事项", + "overview.attentionDescription": "有 {count} 项需要处理。", + "overview.allClear": "当前质量工作流运行正常。", + "overview.failedSyncAction": "查看失败的仓库同步任务。", + "overview.unsyncedAction": "同步尚未生成 commit 快照的仓库。", + "overview.noAttention": "一切就绪,目前没有需要处理的仓库或工作流问题。", + "overview.setupTitle": "工作空间配置", + "overview.setupProgress": "已完成 {completed}/{total} 个必要步骤", + "overview.setupRepository": "添加第一个代码仓库", + "overview.setupSync": "同步一个 commit 快照", + "overview.setupChannel": "连接团队消息渠道", + "overview.synchronized": "已同步", "repositories.title": "代码仓库", "repositories.description": "每个仓库只保留用户显式同步的当前 commit。", "repositories.add": "添加仓库", - "repositories.cloneURL": "克隆地址", - "repositories.refOptional": "分支、Tag 或 commit(可选)", - "repositories.genericHTTPS": "通用 Git HTTPS", - "repositories.connectAndSync": "连接并同步", + "repositories.addAndSync": "添加并同步", "repositories.empty": "暂无代码仓库", "repositories.emptyDescription": "连接一个代码仓库以同步当前源码。", "repositories.cancelSync": "取消同步", "repositories.sync": "立即同步", "repositories.delete": "删除仓库", "repositories.deleteConfirm": "删除 {name} 及其本地源码?", - "repositories.syncRefPrompt": "请输入要同步的分支、Tag 或 commit;留空则使用远程默认分支。", - "repositories.remoteDefault": "远程默认分支", "repositories.notSynced": "尚未同步", + "repositories.remoteUrl": "仓库地址", + "repositories.rename": "重命名仓库", + "repositories.ref": "分支、Tag 或 Commit(ref)", + "repositories.nameOptional": "名称(可选)", + "repositories.namePlaceholder": "留空时根据仓库地址自动生成", + "repositories.credentialOptional": "个人 PAT(可选)", + "repositories.publicAccess": "不使用凭证(公开仓库)", + "repositories.chooseRef": "存在同名分支和 Tag,请选择要跟踪的 ref。", + "repositories.syncHistory": "同步历史", + "repositories.targetExample": "main / v1.0.0 / commit SHA", + "repositories.changeTarget": "修改目标", + "repositories.saveTarget": "保存并同步", + "repositories.searchPlaceholder": "搜索代码仓库", + "repositories.noSearchResults": "没有匹配的代码仓库。", + "repositories.createDescription": "输入 Git 仓库和 ref,MoonCode 将创建托管 mirror 并同步当前 commit。", + "repositories.analyze": "质量分析", + "repositories.currentCommit": "当前 commit", + "repositories.trackedRef": "跟踪 ref", + "repositories.lastSync": "最近同步", + "repositories.mirrorSize": "Mirror 大小", + "repositories.detailTabs": "仓库详情分区", + "repositories.activity": "活动历史", + "repositories.syncHistoryDescription": "仓库同步与源码目标变更记录。", + "repositories.noActivity": "暂无仓库活动", + "repositories.noActivityDescription": "同步操作将显示在这里。", + "repositories.generalSettings": "常规设置", + "repositories.generalSettingsDescription": "修改该仓库在工作空间内显示的名称。", + "repositories.sourceSettings": "仓库来源", + "repositories.sourceSettingsDescription": "修改地址或 ref 后,将同步并生成新的当前 commit 快照。", + "repositories.dangerZone": "危险操作", + "repositories.dangerZoneDescription": "归档仓库,或永久删除其托管 mirror 与历史记录。", + "repositories.copyCommit": "复制 commit SHA", + "repositories.operationProvision": "首次同步", + "repositories.operationRefresh": "同步 ref", + "repositories.operationUpdate": "更新仓库来源", + "repositories.operationPurge": "删除仓库数据", + "repositories.outcomeChanged": "发现新 commit", + "repositories.outcomeNoChange": "已是最新", + + "providerConnections.title": "代码托管连接", + "providerConnections.description": "管理你私有的 GitHub 与 GitLab PAT。连接仅属于你的个人账号。", + "providerConnections.add": "添加代码托管连接", + "providerConnections.baseUrl": "基础地址", + "providerConnections.token": "个人访问令牌", + "providerConnections.privateHint": "仅限你的个人账号使用,不会共享给 Workspace", + "providerConnections.default": "默认连接", + "providerConnections.makeDefault": "设为默认", + "providerConnections.rotate": "更换 PAT", + "providerConnections.newToken": "新的个人访问令牌", + "providerConnections.rotateSave": "验证并更换", + "providerConnections.deleteConfirm": "删除代码托管连接 {name}?", + "providerConnections.testSucceeded": "连接验证成功。", - "connections.title": "代码连接", - "connections.description": "凭据会加密保存,API 永远不会返回凭据明文。", - "connections.new": "新建连接", - "connections.baseURL": "服务地址", - "connections.username": "用户名", - "connections.accessToken": "访问令牌", - "connections.genericGit": "通用 Git", - "connections.save": "保存连接", - "connections.empty": "暂无代码连接", - "connections.emptyDescription": "添加私有 Git 仓库所需的访问凭据。", - "connections.credentialConfigured": "已配置凭据", - "connections.publicAccess": "公开访问", - "connections.delete": "删除连接", - "connections.deleteConfirm": "删除 {name}?", - "connections.displayName": "显示名称", - "connections.replaceToken": "替换访问令牌", - "connections.keepToken": "留空以保留当前令牌", - "connections.repositoryURL": "仓库地址", - "connections.succeeded": "连接成功。", + "analysis.run": "分析快照", + "analysis.snapshot": "Commit 快照", + "analysis.profile": "分析配置", + "analysis.profileOption": "{name} · v{version}", + "analysis.profiles": "分析配置", + "analysis.profilesDescription": "每次任务都会固化版本化的分析器设置。", + "analysis.addProfile": "添加配置", + "analysis.timeoutSeconds": "超时(秒)", + "analysis.saveProfile": "保存配置", + "analysis.createVersion": "创建新版本", + "analysis.profileMetadata": "v{version} · 超时 {timeout} 秒 · scc", + "analysis.title": "质量分析", + "analysis.description": "对当前工作空间内仓库的精确代码快照进行分析。", + "analysis.empty": "暂无可分析的仓库", + "analysis.emptyDescription": "请先添加并同步代码仓库,再运行质量分析。", + "analysis.noSnapshot": "请先同步该仓库,再运行分析。", + "analysis.codeScale": "代码规模", + "analysis.none": "当前快照尚未执行分析。", + "analysis.summary": "{files} 个文件 · {code} 行代码 · {languages} 种语言", + "analysis.manageProfiles": "管理配置", + "analysis.chooseRepository": "选择仓库", + "analysis.stepSnapshot": "1. Commit 快照", + "analysis.stepProfile": "2. 分析配置", + "analysis.noSnapshotTitle": "暂无 commit 快照", + "analysis.noProfilesTitle": "暂无分析配置", + "analysis.noProfilesDescription": "请先创建分析配置,再运行仓库分析。", + "analysis.metricFiles": "文件数", + "analysis.metricCode": "代码行数", + "analysis.metricLanguages": "语言数", + "analysis.metricComplexity": "复杂度", + "analysis.languageDistribution": "语言分布", + "analysis.reportMetadata": "Commit {commit} · 用时 {duration} 毫秒", + "analysis.languageFiles": "{count} 个文件", + "analysis.noReportTitle": "可以开始分析", + "analysis.runHistory": "运行历史", + "analysis.reportTitle": "分析报告", + "analysis.reportSelection": "Commit {commit} · 配置 {profile}", + "analysis.backToSelection": "返回当前选择", + "analysis.runStatus": "分析执行中", + "analysis.runStatusDescription": "阶段:{stage} · 第 {attempt} 次执行", + "analysis.runHistoryDescription": "打开任意历史任务,查看当时的状态或报告。", + "analysis.attempt": "第 {attempt} 次执行", + "analysis.viewReport": "查看报告", + "analysis.metricLines": "总行数", + "analysis.metricComments": "注释行数", + "analysis.metricBlanks": "空白行数", + "analysis.metricBytes": "源码大小", + "analysis.metricDuration": "执行耗时", + "analysis.metricAnalyzer": "分析器", + "analysis.languageShare": "{code} · {percentage}%", + "analysis.warnings": "分析器警告", + "analysis.warningsDescription": "分析已完成,但分析器报告了以下情况。", "channels.title": "消息渠道", "channels.description": "通过托管的长连接接收团队消息。", "channels.add": "添加渠道", + "channelLink.linking": "正在绑定聊天身份…", + "channelLink.success": "聊天身份已绑定到当前 MoonCode 用户。", "channels.engineeringBot": "研发机器人", "channels.appID": "App ID", "channels.clientID": "Client ID", "channels.appSecret": "App Secret", "channels.clientSecret": "Client Secret", + "channels.robotCode": "机器人编码", + "channels.chatID": "目标群聊 ID", + "channels.conversationID": "开放会话 ID", + "channels.useLark": "使用 Lark 国际版接口", "channels.allowedSenders": "允许的发送者 ID", "channels.allowedSendersHelp": "使用逗号分隔;只有信任所有发送者时才使用 *。", "channels.groupPrefix": "群聊前缀", @@ -519,6 +848,21 @@ export const zhCN: Catalog = { "channels.deleteConfirm": "删除 {name}?", "channels.requireMentionShort": "要求 @ 或命令前缀", "channels.saveChanges": "保存更改", + "channels.replacementSecret": "输入新的密钥", + "channels.rotateCredential": "更换凭据", + "channels.notifications": "分析通知", + "channels.notifyStarted": "开始", + "channels.notifySucceeded": "成功", + "channels.notifyFailed": "失败", + "channels.createDescription": "连接飞书、Lark 或钉钉应用,用于接收消息和发送分析通知。", + "channels.connectionStatus": "连接状态", + "channels.lastConnected": "最近连接", + "channels.credential": "凭证", + "channels.configured": "已配置", + "channels.receivingMessages": "正在接收消息", + "channels.notReceivingMessages": "消息接收已暂停", + "channels.configureTitle": "配置 {name}", + "channels.configureDescription": "更新消息访问规则、分析通知和渠道凭证。", "messages.title": "消息", "messages.description": "查看已连接渠道接收并标准化的消息。", @@ -529,17 +873,27 @@ export const zhCN: Catalog = { "messages.empty": "暂无消息", "messages.emptyDescription": "飞书和钉钉的消息会显示在这里。", "messages.via": "来自 {channel}", + "messages.channels": "消息渠道", + "messages.conversations": "会话", + "messages.noChannels": "尚未配置消息渠道。", + "messages.noConversations": "暂无会话。", + "messages.allMessages": "全部消息", + "messages.conversationVia": "通过 {channel} 接收的会话", + "messages.streamDescription": "展示所选渠道与会话中的消息。", - "settings.title": "工作空间设置", - "settings.description": "管理工作空间,并通过已验证的 GitHub 邮箱邀请成员。", + "settings.title": "设置", + "settings.description": "管理你的个人代码托管连接以及当前工作空间。", "settings.general": "基本信息", "settings.workspaceName": "工作空间名称", + "settings.reportRetentionDays": "分析数据保留天数", + "settings.reportRetentionDescription": "超过该期限的终态分析任务和报告会被清理,进行中的任务不会被删除。", "settings.invitations": "邀请", "settings.invitationsDescription": "接收者必须使用此处填写的已验证 GitHub 邮箱登录。", "settings.email": "邮箱", - "settings.role": "角色", "settings.createInvitation": "创建邀请", "settings.copyInvitation": "请立即复制此链接,邀请令牌只会返回一次。", + "settings.noInvitations": "暂无邀请记录。", + "settings.noMembers": "暂无工作空间成员。", "settings.accepted": "已接受", "settings.revoked": "已撤销", "settings.expired": "已过期", @@ -550,11 +904,19 @@ export const zhCN: Catalog = { "settings.createWorkspace": "创建其他工作空间", "settings.create": "创建", "settings.emptyModule": "{title} 已准备好承载下一阶段产品能力。", + "settings.tabs": "设置分区", + "settings.workspaceTab": "工作空间", + "settings.teamTab": "团队成员与邀请", + "settings.credentialsTab": "个人代码凭证", + "settings.createWorkspaceDescription": "创建一个仓库、成员和渠道完全独立的新工作空间。", + "settings.workspaceManageDescription": "管理工作空间信息和分析报告保留周期。", + "settings.workspaceReadOnlyDescription": "只有工作空间所有者和管理员可以修改这些设置。", + "settings.adminOnlyDescription": "只有工作空间所有者和管理员可以创建或撤销邀请。", + "settings.roleOwner": "所有者", + "settings.roleAdmin": "管理员", + "settings.roleMember": "成员", + "settings.removeMemberConfirm": "将 {name} 移出当前工作空间?", - "role.owner": "所有者", - "role.admin": "管理员", - "role.member": "成员", - "role.viewer": "访客", "state.pending": "等待中", "state.queued": "排队中", "state.active": "活跃", @@ -566,6 +928,7 @@ export const zhCN: Catalog = { "state.running": "运行中", "state.connected": "已连接", "state.disabled": "已停用", + "state.error": "错误", "state.uploading": "上传中", "state.deleting": "删除中", "state.cancelled": "已取消", @@ -573,6 +936,9 @@ export const zhCN: Catalog = { "state.retry_wait": "等待重试", "state.suspended": "已暂停", "state.deleted": "已删除", + "state.owner": "所有者", + "state.admin": "管理员", + "state.member": "成员", "error.http.request_failed": "请求失败。", "error.request.invalid_json": "请求内容格式不正确。", @@ -605,8 +971,26 @@ export const zhCN: Catalog = { "error.workspace.list_failed": "无法加载工作空间列表。", "error.workspace.create_failed": "无法创建工作空间。", "error.workspace.update_failed": "无法更新工作空间。", + "error.workspace.update_required": "请至少修改一项工作空间设置。", + "error.workspace.name_required": "必须填写工作空间名称。", + "error.workspace.report_retention_invalid": "分析数据保留天数必须在 1 到 3650 之间。", "error.workspace.members_failed": "无法加载工作空间成员。", "error.workspace.member_failed": "无法更新工作空间成员。", + "error.workspace.last_member": "工作空间必须至少保留一名成员。", + "error.workspace.forbidden": "你没有访问此工作空间的权限。", + "error.workspace.invitation_email_invalid": "请输入有效的邀请邮箱。", + "error.workspace.invitation_not_found": "未找到工作空间邀请。", + "error.workspace.invitation_recipient_mismatch": "此邀请属于其他账户。", + "error.workspace.invitation_unavailable": "此工作空间邀请已不可用。", + "error.workspace.member_not_removable": "无法移除此工作空间成员。", + "error.account.registration_closed": "当前未开放注册。", + "error.account.terms_required": "请接受服务条款和隐私政策后继续。", + "error.auth.identity_failed": "无法验证登录身份。", + "error.auth.required": "请登录后继续。", + "error.request.body_invalid": "请求内容无效。", + "error.request.body_too_large": "请求内容过大。", + "error.request.page_invalid": "请求的分页参数无效。", + "error.request.parameter_invalid": "请求参数无效。", "error.user.invalid_id": "用户 ID 无效。", "error.invitation.invalid_id": "邀请 ID 无效。", "error.invitation.accept_failed": "无法接受邀请。", @@ -619,17 +1003,25 @@ export const zhCN: Catalog = { "error.channel.update_failed": "无法更新渠道。", "error.channel.delete_failed": "无法删除渠道。", "error.channel.state_failed": "无法更改渠道状态。", + "error.channel.subscription_event_invalid": "选择的分析通知事件无效。", + "error.channel.allowlist_invalid": "消息渠道发送者白名单无效。", + "error.channel.allowlist_required": "请至少配置一名允许的发送者。", + "error.channel.configuration_stale": "消息渠道配置已变更,请刷新后重试。", + "error.channel.credential_missing": "消息渠道凭证缺失。", + "error.channel.credential_required": "请输入消息渠道凭证。", + "error.channel.credentials_invalid": "消息渠道凭证无效。", + "error.channel.group_prefix_invalid": "群聊命令前缀无效。", + "error.channel.identity_link_invalid": "消息身份绑定链接无效或已过期。", + "error.channel.name_required": "必须填写消息渠道名称。", + "error.channel.not_found": "未找到消息渠道。", + "error.channel.type_invalid": "消息渠道类型无效。", + "error.channel.value_required": "缺少必填的消息渠道设置。", + "error.channel.values_invalid": "消息渠道设置无效。", "error.channel.status_failed": "无法加载渠道状态。", "error.conversation.invalid_id": "会话 ID 无效。", "error.conversation.list_failed": "无法加载会话列表。", "error.message.list_failed": "无法加载消息。", "error.overview.get_failed": "无法加载概览。", - "error.connection.invalid_id": "连接 ID 无效。", - "error.connection.create_failed": "无法创建连接。", - "error.connection.list_failed": "无法加载连接列表。", - "error.connection.update_failed": "无法更新连接。", - "error.connection.delete_failed": "无法删除连接。", - "error.connection.test_failed": "无法验证连接。", "error.repository.invalid_id": "代码仓库 ID 无效。", "error.repository.create_failed": "无法创建代码仓库。", "error.repository.list_failed": "无法加载代码仓库列表。", @@ -638,6 +1030,50 @@ export const zhCN: Catalog = { "error.repository.delete_failed": "无法删除代码仓库。", "error.repository.sync_failed": "无法同步代码仓库。", "error.repository.cancel_failed": "无法取消同步。", + "error.repository.preview_failed": "无法访问代码仓库或识别 ref。", + "error.repository.rename_failed": "无法重命名代码仓库。", + "error.repository.target_failed": "无法修改代码仓库目标。", + "error.repository.archive_failed": "无法修改代码仓库归档状态。", + "error.repository.workspace_quota_exceeded": "工作空间已达到代码仓库数量上限。", + "error.repository.deletion_conflict": "当前状态下无法删除代码仓库。", + "error.repository.deletion_unavailable": "请等待当前代码仓库操作完成,或先取消操作再删除。", + "error.repository.operation_not_cancellable": "代码仓库操作已无法取消。", + "error.repository.ref_required": "请输入分支、标签、ref 或 commit SHA。", + "error.repository.refresh_unavailable": "当前状态下无法刷新代码仓库。", + "error.repository.update_unavailable": "当前状态下无法修改代码仓库来源。", + "error.repository.remote_invalid": "代码仓库地址无效或不受支持。", + "error.repository.source_conflict": "该代码仓库来源已由当前工作空间管理。", + "error.repository.state_conflict": "请求操作期间代码仓库状态已变化,请刷新后重试。", + "error.analysis.invalid_id": "分析运行 ID 无效。", + "error.analysis.list_failed": "无法加载分析历史。", + "error.analysis.get_failed": "无法加载分析运行。", + "error.analysis.create_failed": "无法开始分析。", + "error.analysis.cancel_failed": "无法取消分析运行。", + "error.analysis.retry_failed": "无法重试分析运行。", + "error.analysis.repository_unavailable": "当前代码仓库无法执行分析。", + "error.analysis.snapshot_required": "请先同步代码仓库再开始分析。", + "error.analysis.snapshot_unavailable": "选择的 commit 快照已不可用。", + "error.analysis.profile_definition_invalid": "分析配置定义无效。", + "error.analysis.profile_definition_unsupported": "分析配置包含不支持的设置。", + "error.analysis.profile_timeout_invalid": "配置超时必须在 1 到 86400 秒之间。", + "error.analysis.profile_name_required": "必须填写分析配置名称。", + "error.analysis.profile_version_invalid": "分析配置版本无效。", + "error.analysis.profile_version_stale": "分析配置已变更,请刷新后重试。", + "error.analysis.profile_not_found": "未找到分析配置。", + "error.analysis.profile_required": "请先创建分析配置再开始分析。", + "error.analysis.profile_last_active": "至少保留一个有效的分析配置。", + "error.analysis.profile_snapshot_invalid": "任务固化的分析配置无效。", + "error.analysis.workspace_concurrency_exceeded": "工作空间已达到并发分析上限。", + "error.analysis.not_cancellable": "分析运行已无法取消。", + "error.analysis.not_retryable": "分析运行无法重试。", + "error.provider.connection_not_found": "未找到代码托管连接。", + "error.provider.connection_mismatch": "所选个人 PAT 与该仓库地址不匹配。", + "error.provider.connection_inactive": "所选代码托管连接当前未启用。", + "error.provider.connection_unavailable": "代码托管连接当前不可用。", + "error.provider.credential_invalid": "个人访问令牌无效。", + "error.provider.credential_stale": "个人访问令牌已变更,请使用当前凭证重试。", + "error.provider.token_required": "请输入个人访问令牌。", + "error.provider.type_invalid": "代码托管平台类型无效。", "error.job.invalid_id": "任务 ID 无效。", "error.job.get_failed": "无法加载任务。", "error.job.cancel_failed": "无法取消任务。", diff --git a/frontend/src/i18n/context.ts b/frontend/src/i18n/context.ts index fc07018..c979608 100644 --- a/frontend/src/i18n/context.ts +++ b/frontend/src/i18n/context.ts @@ -10,7 +10,6 @@ export type I18nContextValue = { t: (key: TranslationKey, params?: TranslationParams) => string formatDateTime: (value: string | number | Date) => string formatState: (state: string) => string - formatRole: (role: string) => string formatError: (error: unknown) => string } diff --git a/frontend/src/i18n/format.ts b/frontend/src/i18n/format.ts index 0729658..d7d1362 100644 --- a/frontend/src/i18n/format.ts +++ b/frontend/src/i18n/format.ts @@ -21,10 +21,6 @@ export function formatState(catalog: Catalog, state: string): string { return catalogValue(catalog, `state.${state}`, state) } -export function formatRole(catalog: Catalog, role: string): string { - return catalogValue(catalog, `role.${role}`, role) -} - export function formatError(catalog: Catalog, error: unknown): string { const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : "" return catalogValue(catalog, `error.${code}`, catalog["error.fallback"]) diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 3c54712..468daad 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -8,24 +8,19 @@ export class APIError extends Error { readonly status: number readonly code: string readonly requestId: string - readonly loginURL: string - constructor(status: number, body: ErrorBody | null, loginURL = "") { + constructor(status: number, body: ErrorBody | null) { super(body?.error?.message ?? `Request failed (${status})`) this.name = "APIError" this.status = status this.code = body?.error?.code ?? "http.request_failed" this.requestId = body?.error?.requestId ?? "" - this.loginURL = loginURL } } async function responseError(response: Response): Promise { const body = await response.clone().json().catch(() => null) as ErrorBody | null - const location = response.status === 401 ? response.headers.get("X-Tinyauth-Location") ?? "" : "" - const loginURL = location ? new URL(location, window.location.origin) : null - if (loginURL) loginURL.searchParams.set("redirect_uri", window.location.href) - return new APIError(response.status, body, loginURL?.toString() ?? "") + return new APIError(response.status, body) } async function issueCSRFToken(): Promise { diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 47a3567..bc01ef7 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -1,33 +1,23 @@ -export type Workspace = { id: string; name: string; role: string; createdBy: string } -export type User = { - id: string - displayName: string - username: string - email: string - status: "pending" | "active" | "suspended" | "deleted" -} -export type Session = { - user: User - workspaces: Workspace[] - auth: { logoutUrl: string } - registration: { mode: "disabled" | "invite_only" | "public"; termsVersion: string; privacyVersion: string } -} -export type WorkspaceInvitation = { - id: string - workspaceId: string - email: string - role: "admin" | "member" | "viewer" - token?: string - expiresAt: string - acceptedAt?: string - revokedAt?: string - createdAt: string -} -export type Repository = { id: string; name: string; cloneUrl: string; ref: string; currentCommitSha?: string; state: string; lastErrorMessage?: string; syncedAt?: string; updatedAt: string } -export type Connection = { id: string; name: string; type: string; baseUrl: string; authType: string; secretConfigured: boolean } -export type ChannelInstance = { id: string; type: string; name: string; enabled: boolean; secretConfigured: boolean; configVersion: number; config: { values: Record; senderAllowList: string[]; groupPolicy: { requireMention: boolean; prefix: string } } } +export type Workspace = { id: string; name: string; slug: string; reportRetentionDays: number; createdBy: string; role: "member" | "admin" | "owner" } +export type User = { id: string; displayName: string; username: string; email: string; status: "pending" | "active" | "suspended" | "deleted" } +export type Session = { user: User; workspaces: Workspace[]; auth: { logoutUrl: string }; registration: { mode: "disabled" | "invite_only" | "public"; termsVersion: string; privacyVersion: string } } +export type WorkspaceInvitation = { id: string; workspaceId: string; email: string; token?: string; expiresAt: string; acceptedAt?: string; revokedAt?: string; createdAt: string } +export type WorkspaceMember = { userId: string; username: string; email: string; displayName: string; role: "member" | "admin" | "owner"; createdAt: string } +export type PageResponse = { items: T[]; nextCursor?: string } + +export type ProviderConnection = { id: string; providerType: "github" | "gitlab"; baseUrl: string; providerAccountId?: string; login?: string; displayName?: string; scopes: string[]; isDefault: boolean; status: "active" | "invalid" | "revoked"; credentialVersion: number; lastValidatedAt?: string; lastUsedAt?: string; lastErrorCode?: string; createdAt: string; updatedAt: string } +export type RepositorySnapshot = { id: string; repositoryId: string; commitSha: string; sourceRef: string; authorName?: string; authoredAt?: string; title?: string; sourceState: "available" | "purging" | "purged"; createdAt: string } +export type RepositoryOperation = { id: string; repositoryId: string; actorUserId: string; providerConnectionId?: string; credentialVersion?: number; repositoryVersion: number; kind: "provision" | "refresh" | "update" | "purge"; requestedProviderType: "github" | "gitlab"; requestedRemoteUrl: string; requestedNormalizedUrl: string; requestedRef: string; status: string; outcome?: "changed" | "no_change"; resolvedCommitSha?: string; snapshotId?: string; errorMessage?: string; createdAt: string; startedAt?: string; finishedAt?: string } +export type Repository = { id: string; workspaceId: string; providerType: "github" | "gitlab"; name: string; remoteUrl: string; normalizedUrl: string; ref: string; configVersion: number; status: "provisioning" | "syncing" | "ready" | "failed" | "deleting" | "deleted"; currentSnapshotId?: string; currentSnapshot?: RepositorySnapshot; mirrorSizeBytes: number; lastSyncAt?: string; lastErrorCode?: string; lastErrorMessage?: string; archivedAt?: string; deletedAt?: string; createdAt: string; updatedAt: string } +export type CodeScaleResult = { summary: { files: number; lines: number; code: number; comments: number; blanks: number; bytes: number; complexity: number }; languages: Array<{ name: string; files: number; lines: number; code: number; comments: number; blanks: number; bytes: number; complexity: number }>; warnings: string[] } +export type AnalysisProfileDefinition = { dimensionKey: "code_scale"; timeoutSeconds: number; analyzer: { key: "scc"; required: true } } +export type AnalysisProfile = { id: string; workspaceId: string; name: string; currentVersion: number; dimensionKey: "code_scale"; definition: AnalysisProfileDefinition; createdBy: string; createdAt: string; updatedAt: string; versionCreatedAt: string } +export type AnalysisReport = { id: string; analysisRunId: string; repositoryId: string; snapshotId: string; commitSha: string; sourceRef: string; commitAuthor?: string; commitAuthoredAt?: string; commitTitle?: string; dimensionKey: string; profileId: string; profileVersion: string; profileSnapshot: AnalysisProfileDefinition; analyzerVersion: string; executionEnvironment: string; startedAt: string; finishedAt: string; durationMs: number; result: CodeScaleResult; createdAt: string } +export type AnalysisRun = { id: string; repositoryId: string; snapshotId: string; commitSha: string; dimensionKey: string; profileId: string; profileVersion: string; profileSnapshot: AnalysisProfileDefinition; analyzerVersion: string; attempt: number; rerunOf?: string; status: string; stage: "queued" | "prepare" | "authorize" | "checkout" | "analyze" | "persist" | "workflow" | "complete" | "cancelled"; reportId?: string; report?: AnalysisReport; workflowRunId?: string; failedStage?: "prepare" | "authorize" | "checkout" | "analyze" | "persist" | "workflow"; errorCode?: string; errorMessage?: string; retryable: boolean; createdAt: string; startedAt?: string; finishedAt?: string } + +export type ChannelNotificationEvent = "analysis.started" | "analysis.succeeded" | "analysis.failed" +export type ChannelInstance = { id: string; type: string; name: string; enabled: boolean; secretConfigured: boolean; configVersion: number; config: { values: Record; senderAllowList: string[]; groupPolicy: { requireMention: boolean; prefix: string } }; notificationEvents: ChannelNotificationEvent[] } export type ChannelStatus = { state: string; lastConnectedAt?: string; lastErrorMessage?: string } export type IMMessage = { id: string; channelInstanceId: string; channelName: string; channelType: string; conversationId: string; conversationExternalId: string; senderCanonicalId: string; senderDisplayName: string; conversationType: string; content: { content: { type: string; text: string } }; occurredAt: string } export type IMConversation = { id: string; channelInstanceId: string; channelName: string; channelType: string; externalId: string; type: string; title: string } -export type WorkspaceOverview = { repositoryCount: number; activeChannelCount: number; failedJobCount: number; recentMessages: IMMessage[] } -export type WorkspaceMember = { userId: string; role: string; username: string; email: string; displayName: string } +export type WorkspaceOverview = { repositoryCount: number; repositoryWithoutCodeCount: number; recentFailedSyncCount: number; activeAnalysisCount: number; activeChannelCount: number; recentMessages: IMMessage[] } diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..ef1fa8e --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,55 @@ +type OAuthURLResponse = { + url?: unknown +} + +export async function oauthRedirectURL(): Promise { + const endpointURL = sameOriginURL("/api/oauth/url/github") + endpointURL.searchParams.set("redirect_uri", window.location.href) + const response = await fetch(endpointURL, { + credentials: "include", + headers: { Accept: "application/json" }, + }) + if (!response.ok) throw new Error(`OAuth initialization failed (${response.status})`) + + const payload = await response.json() as OAuthURLResponse + if (typeof payload.url !== "string") throw new Error("OAuth initialization returned an invalid response") + + const redirectURL = new URL(payload.url) + if (redirectURL.origin !== "https://github.com" || redirectURL.pathname !== "/login/oauth/authorize") { + throw new Error("OAuth initialization returned an unsafe redirect") + } + + return redirectURL.toString() +} + +export async function logout(endpoint: string): Promise { + const response = await fetch(sameOriginURL(endpoint), { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }) + if (!response.ok) throw new Error(`Logout failed (${response.status})`) +} + +export function postLoginRedirectURL(value: string | null): string { + const fallbackURL = new URL("/app", window.location.origin) + if (!value) return fallbackURL.toString() + + try { + const redirectURL = new URL(value) + const isWorkbench = redirectURL.origin === window.location.origin && redirectURL.pathname === "/app" + const hasCredentials = Boolean(redirectURL.username || redirectURL.password) + if (!isWorkbench || hasCredentials) return fallbackURL.toString() + + return redirectURL.toString() + } catch { + return fallbackURL.toString() + } +} + +function sameOriginURL(value: string): URL { + const url = new URL(value, window.location.origin) + if (url.origin !== window.location.origin) throw new Error("Authentication endpoint must use the application origin") + + return url +} diff --git a/go.mod b/go.mod index 9d54258..8af2566 100644 --- a/go.mod +++ b/go.mod @@ -1,141 +1,107 @@ -module github.com/mooncode-ai/mooncode +module github.com/fuchencong/mooncode -go 1.25.0 +go 1.26.5 require ( - github.com/exaring/otelpgx v0.11.1 - github.com/gin-gonic/gin v1.12.0 - github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/getkin/kin-openapi v0.144.0 + github.com/gin-gonic/gin v1.10.1 github.com/google/uuid v1.6.0 - github.com/gorilla/csrf v1.7.3 + github.com/hatchet-dev/hatchet v0.98.9 github.com/jackc/pgx/v5 v5.9.2 - github.com/larksuite/oapi-sdk-go/v3 v3.9.9 - github.com/minio/minio-go/v7 v7.0.95 + github.com/larksuite/oapi-sdk-go/v3 v3.9.4 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 - github.com/prometheus/client_golang v1.23.2 - github.com/rs/zerolog v1.35.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.11.1 - github.com/testcontainers/testcontainers-go v0.43.0 - go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 - go.opentelemetry.io/otel/sdk v1.43.0 - go.opentelemetry.io/otel/trace v1.43.0 - go.uber.org/dig v1.19.0 - golang.org/x/sync v0.20.0 ) require ( - dario.cat/mergo v1.0.2 // indirect - github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect + cel.dev/expr v0.25.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudwego/base64x v0.1.6 // indirect - github.com/containerd/errdefs v1.0.0 // indirect - github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect - github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.6.0 // indirect - github.com/docker/go-units v0.5.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/purego v0.10.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/cockroachdb/errors v1.12.0 // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/redact v1.1.5 // indirect + github.com/creasty/defaults v1.8.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-ini/ini v1.67.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/getsentry/sentry-go v0.45.1 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect - github.com/gorilla/websocket v1.5.0 // indirect + github.com/google/cel-go v0.29.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.5 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/labstack/echo/v4 v4.15.1 // indirect + github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/minio/crc64nvme v1.0.2 // indirect - github.com/minio/md5-simd v1.1.2 // indirect - github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/go-archive v0.2.0 // indirect - github.com/moby/moby/api v1.54.2 // indirect - github.com/moby/moby/client v0.4.0 // indirect - github.com/moby/patternmatcher v0.6.1 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/sys/user v0.4.0 // indirect - github.com/moby/sys/userns v0.1.0 // indirect - github.com/moby/term v0.5.2 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/oapi-codegen/runtime v1.4.0 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/philhofer/fwd v1.2.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect - github.com/rs/xid v1.6.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/zerolog v1.35.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/shirou/gopsutil/v4 v4.26.5 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tinylib/msgp v1.3.0 // indirect - github.com/tklauser/go-sysconf v0.3.16 // indirect - github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.1 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect + golang.org/x/text v0.39.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0e69dc3..33603ee 100644 --- a/go.sum +++ b/go.sum @@ -1,233 +1,186 @@ -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= -github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= -github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= -github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= -github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo= +github.com/cockroachdb/errors v1.12.0/go.mod h1:SvzfYNNBshAVbZ8wzNc/UPK3w1vf0dKDUP41ucAIf7g= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= +github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= -github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/exaring/otelpgx v0.11.1 h1:pE79fIg/qh/Lpu00kvswFC5dKfqyJJhMJ4Y4N3w5Lj4= -github.com/exaring/otelpgx v0.11.1/go.mod h1:3OojrUKhhy3lTbYIMBijP3YjMey/jo14eHAW5cXcUdk= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= -github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= -github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= -github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y= +github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/getsentry/sentry-go v0.45.1 h1:9rfzJtGiJG+MGIaWZXidDGHcH5GU1Z5y0WVJGf9nysw= +github.com/getsentry/sentry-go v0.45.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= -github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= -github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= +github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= -github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hatchet-dev/hatchet v0.98.9 h1:IMGji0rBwufWWsKdC3hnveaSTWYlii958M9coGJF18w= +github.com/hatchet-dev/hatchet v0.98.9/go.mod h1:p7lWN5o+bZiDIUNOkA/klt4q//IRTv64GrPNvNWL4c8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= -github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgxlisten v0.0.0-20241106001234-1d6f6656415c h1:bTgmg761ac9Ki27HoLx8IBvc+T+Qj6eptBpKahKIRT4= +github.com/jackc/pgxlisten v0.0.0-20241106001234-1d6f6656415c/go.mod h1:N4E1APLOYrbM11HH5kdqAjDa8RJWVwD3JqWpvH22h64= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/larksuite/oapi-sdk-go/v3 v3.9.9 h1:qzVK5U1AuT/n0Z4LqCN2ATU6MlYp2Iipa7q+fVK5Nh8= -github.com/larksuite/oapi-sdk-go/v3 v3.9.9/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs= +github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/larksuite/oapi-sdk-go/v3 v3.9.4 h1:oMgcY7NBjJv1QXJqFAfcoN/TbScCkCuRZfbb1mCwZmI= +github.com/larksuite/oapi-sdk-go/v3 v3.9.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= -github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg= -github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= -github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= -github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= -github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= -github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= -github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= -github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= -github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= -github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= -github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= -github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= +github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= -github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= -github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= -github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -241,50 +194,39 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= -github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= -github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= -github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= -github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= -github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= -github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= -github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= -github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -295,51 +237,43 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= -go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= -go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= -golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -350,12 +284,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -364,7 +298,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= -pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/analysis/biz/failure.go b/internal/analysis/biz/failure.go new file mode 100644 index 0000000..82b5e04 --- /dev/null +++ b/internal/analysis/biz/failure.go @@ -0,0 +1,8 @@ +package biz + +type Failure struct { + Stage string + Code string + Message string + Retryable bool +} diff --git a/internal/analysis/biz/idempotency.go b/internal/analysis/biz/idempotency.go new file mode 100644 index 0000000..f5cf1ea --- /dev/null +++ b/internal/analysis/biz/idempotency.go @@ -0,0 +1,25 @@ +package biz + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "github.com/google/uuid" +) + +func idempotencyKey(workspaceID, repositoryID, snapshotID, profileID uuid.UUID, dimensionKey, profileVersion, analyzerVersion string, profileSnapshot []byte) string { + profileDigest := sha256.Sum256(profileSnapshot) + digest := sha256.Sum256([]byte(strings.Join([]string{ + workspaceID.String(), + repositoryID.String(), + snapshotID.String(), + profileID.String(), + dimensionKey, + profileVersion, + analyzerVersion, + hex.EncodeToString(profileDigest[:]), + }, "\x00"))) + + return hex.EncodeToString(digest[:]) +} diff --git a/internal/analysis/biz/idempotency_test.go b/internal/analysis/biz/idempotency_test.go new file mode 100644 index 0000000..a9442bc --- /dev/null +++ b/internal/analysis/biz/idempotency_test.go @@ -0,0 +1,34 @@ +package biz + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestIdempotencyKeyIsStableForSameAnalysisTarget(t *testing.T) { + workspaceID, repositoryID, snapshotID := uuid.New(), uuid.New(), uuid.New() + profileID := uuid.New() + profileSnapshot := []byte(`{"dimensionKey":"code_scale"}`) + + first := idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.7.0", profileSnapshot) + second := idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.7.0", profileSnapshot) + if first != second || len(first) != 64 { + t.Fatalf("unstable idempotency key: %q %q", first, second) + } + if first == idempotencyKey(workspaceID, repositoryID, uuid.New(), profileID, "code_scale", "v1", "scc-3.7.0", profileSnapshot) { + t.Fatal("different snapshots produced the same idempotency key") + } + if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v2", "scc-3.7.0", profileSnapshot) { + t.Fatal("different profile versions produced the same idempotency key") + } + if first == idempotencyKey(workspaceID, repositoryID, snapshotID, uuid.New(), "code_scale", "v1", "scc-3.7.0", profileSnapshot) { + t.Fatal("different profiles produced the same idempotency key") + } + if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.7.0", []byte(`{"dimensionKey":"other"}`)) { + t.Fatal("different profile snapshots produced the same idempotency key") + } + if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.8.0", profileSnapshot) { + t.Fatal("different analyzer versions produced the same idempotency key") + } +} diff --git a/internal/analysis/biz/model.go b/internal/analysis/biz/model.go new file mode 100644 index 0000000..703fe29 --- /dev/null +++ b/internal/analysis/biz/model.go @@ -0,0 +1,71 @@ +package biz + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type Run struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspaceId"` + RepositoryID uuid.UUID `json:"repositoryId"` + SnapshotID uuid.UUID `json:"snapshotId"` + CommitSHA string `json:"commitSha"` + RequestedBy uuid.UUID `json:"requestedBy"` + DimensionKey string `json:"dimensionKey"` + ProfileID uuid.UUID `json:"profileId"` + ProfileVersion string `json:"profileVersion"` + ProfileSnapshot json.RawMessage `json:"profileSnapshot"` + AnalyzerVersion string `json:"analyzerVersion"` + IdempotencyKey string `json:"-"` + Attempt int32 `json:"attempt"` + RerunOf *uuid.UUID `json:"rerunOf,omitempty"` + Status string `json:"status"` + Stage string `json:"stage"` + ReportID *uuid.UUID `json:"reportId,omitempty"` + Report *Report `json:"report,omitempty"` + WorkflowRunID string `json:"workflowRunId,omitempty"` + FailedStage string `json:"failedStage,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + Retryable bool `json:"retryable"` + CreatedAt time.Time `json:"createdAt"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` +} + +type Report struct { + ID uuid.UUID `json:"id"` + AnalysisRunID uuid.UUID `json:"analysisRunId"` + WorkspaceID uuid.UUID `json:"workspaceId"` + RepositoryID uuid.UUID `json:"repositoryId"` + SnapshotID uuid.UUID `json:"snapshotId"` + CommitSHA string `json:"commitSha"` + SourceRef string `json:"sourceRef"` + CommitAuthor string `json:"commitAuthor,omitempty"` + CommitAuthoredAt *time.Time `json:"commitAuthoredAt,omitempty"` + CommitTitle string `json:"commitTitle,omitempty"` + DimensionKey string `json:"dimensionKey"` + ProfileID uuid.UUID `json:"profileId"` + ProfileVersion string `json:"profileVersion"` + ProfileSnapshot json.RawMessage `json:"profileSnapshot"` + AnalyzerVersion string `json:"analyzerVersion"` + ExecutionEnvironment string `json:"executionEnvironment"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + DurationMS int64 `json:"durationMs"` + Result json.RawMessage `json:"result"` + RawArtifact json.RawMessage `json:"-"` + CreatedAt time.Time `json:"createdAt"` +} + +type Execution struct { + Environment string +} + +type WorkItem struct { + Run Run + CommitSHA string +} diff --git a/internal/analysis/biz/profile.go b/internal/analysis/biz/profile.go new file mode 100644 index 0000000..2a915cc --- /dev/null +++ b/internal/analysis/biz/profile.go @@ -0,0 +1,127 @@ +package biz + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "time" + + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" +) + +type Profile struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspaceId"` + Name string `json:"name"` + CurrentVersion int32 `json:"currentVersion"` + DimensionKey string `json:"dimensionKey"` + Definition json.RawMessage `json:"definition"` + CreatedBy uuid.UUID `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + VersionCreatedAt time.Time `json:"versionCreatedAt"` +} + +type ProfileDefinition struct { + DimensionKey string `json:"dimensionKey"` + TimeoutSeconds int64 `json:"timeoutSeconds"` + Analyzer struct { + Key string `json:"key"` + Required bool `json:"required"` + } `json:"analyzer"` +} + +func DefaultProfileDefinition() json.RawMessage { + return json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":300,"analyzer":{"key":"scc","required":true}}`) +} + +func normalizeProfileDefinition(raw json.RawMessage) (json.RawMessage, string, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var definition ProfileDefinition + if err := decoder.Decode(&definition); err != nil { + return nil, "", fault.New(fault.Invalid, "analysis.profile_definition_invalid", "Analysis profile definition is invalid") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, "", fault.New(fault.Invalid, "analysis.profile_definition_invalid", "Analysis profile definition is invalid") + } + if definition.DimensionKey != "code_scale" || definition.Analyzer.Key != "scc" || !definition.Analyzer.Required { + return nil, "", fault.New(fault.Invalid, "analysis.profile_definition_unsupported", "Analysis profile definition is unsupported") + } + if definition.TimeoutSeconds < 1 || definition.TimeoutSeconds > 86400 { + return nil, "", fault.New(fault.Invalid, "analysis.profile_timeout_invalid", "Analysis profile timeout must be between 1 and 86400 seconds") + } + encoded, err := json.Marshal(definition) + if err != nil { + return nil, "", err + } + + return encoded, definition.DimensionKey, nil +} + +func (s *Service) Profiles(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[Profile], error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return pagination.Page[Profile]{}, err + } + + return s.store.ListProfiles(ctx, workspaceID, page) +} + +func (s *Service) CreateProfile(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, name string, definition json.RawMessage) (Profile, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "admin"); err != nil { + return Profile{}, err + } + name = strings.TrimSpace(name) + if name == "" { + return Profile{}, fault.New(fault.Invalid, "analysis.profile_name_required", "Analysis profile name is required") + } + definition, dimensionKey, err := normalizeProfileDefinition(definition) + if err != nil { + return Profile{}, err + } + + return s.store.CreateProfile(ctx, workspaceID, actor.UserID, name, dimensionKey, definition) +} + +func (s *Service) UpdateProfile(ctx context.Context, actor auth.Actor, workspaceID, profileID uuid.UUID, currentVersion int32, name string, definition json.RawMessage) (Profile, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "admin"); err != nil { + return Profile{}, err + } + name = strings.TrimSpace(name) + if name == "" { + return Profile{}, fault.New(fault.Invalid, "analysis.profile_name_required", "Analysis profile name is required") + } + if currentVersion < 1 { + return Profile{}, fault.New(fault.Invalid, "analysis.profile_version_invalid", "Analysis profile version is invalid") + } + definition, dimensionKey, err := normalizeProfileDefinition(definition) + if err != nil { + return Profile{}, err + } + profile, err := s.store.UpdateProfile(ctx, workspaceID, actor.UserID, profileID, currentVersion, name, dimensionKey, definition) + if err != nil { + return Profile{}, err + } + + return profile, nil +} + +func (s *Service) ArchiveProfile(ctx context.Context, actor auth.Actor, workspaceID, profileID uuid.UUID) error { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "admin"); err != nil { + return err + } + ok, err := s.store.ArchiveProfile(ctx, workspaceID, actor.UserID, profileID) + if err != nil { + return err + } + if !ok { + return fault.New(fault.NotFound, "analysis.profile_not_found", "Analysis profile was not found") + } + + return nil +} diff --git a/internal/analysis/biz/profile_test.go b/internal/analysis/biz/profile_test.go new file mode 100644 index 0000000..9bdbbc6 --- /dev/null +++ b/internal/analysis/biz/profile_test.go @@ -0,0 +1,24 @@ +package biz + +import ( + "encoding/json" + "testing" +) + +func TestProfileDefinitionValidation(t *testing.T) { + definition, dimension, err := normalizeProfileDefinition(DefaultProfileDefinition()) + if err != nil || dimension != "code_scale" || !json.Valid(definition) { + t.Fatalf("default definition = (%s, %q, %v)", definition, dimension, err) + } + invalid := []json.RawMessage{ + json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":0,"analyzer":{"key":"scc","required":true}}`), + json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":10,"analyzer":{"key":"other","required":true}}`), + json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":10,"analyzer":{"key":"scc","required":true},"unknown":true}`), + json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":10,"analyzer":{"key":"scc","required":true}} {}`), + } + for _, candidate := range invalid { + if _, _, err := normalizeProfileDefinition(candidate); err == nil { + t.Fatalf("invalid profile definition was accepted: %s", candidate) + } + } +} diff --git a/internal/analysis/biz/service.go b/internal/analysis/biz/service.go new file mode 100644 index 0000000..f5dfb4a --- /dev/null +++ b/internal/analysis/biz/service.go @@ -0,0 +1,170 @@ +package biz + +import ( + "context" + "fmt" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/fuchencong/mooncode/pkg/analyzer/scc" + "github.com/google/uuid" +) + +type Authorizer interface { + Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) +} +type Repositories interface { + Get(context.Context, uuid.UUID, uuid.UUID) (repository.Repository, error) + Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (repository.Snapshot, error) +} + +type Service struct { + store Store + authorizer Authorizer + repositories Repositories + canceller Canceller +} + +func NewService(store Store, authorizer Authorizer, repositories Repositories, canceller Canceller) *Service { + return &Service{store: store, authorizer: authorizer, repositories: repositories, canceller: canceller} +} + +type CreateInput struct { + SnapshotID uuid.UUID + ProfileID uuid.UUID +} + +func (s *Service) Create(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, input CreateInput) (Run, error) { + return s.create(ctx, actor, workspaceID, repositoryID, input, 1, nil, nil) +} + +type frozenProfile struct { + ID uuid.UUID + Version string + DimensionKey string + Snapshot []byte +} + +func (s *Service) create(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, input CreateInput, attempt int32, rerunOf *uuid.UUID, frozen *frozenProfile) (Run, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Run{}, err + } + repo, err := s.repositories.Get(ctx, workspaceID, repositoryID) + if err != nil { + return Run{}, err + } + if repo.ArchivedAt != nil || (repo.Status != "ready" && repo.Status != "syncing") { + return Run{}, fault.New(fault.Conflict, "analysis.repository_unavailable", "Repository is not available for analysis") + } + var snapshot repository.Snapshot + if input.SnapshotID == uuid.Nil { + if repo.CurrentSnapshot == nil { + return Run{}, fault.New(fault.Conflict, "analysis.snapshot_required", "Repository has no available commit snapshot") + } + snapshot = *repo.CurrentSnapshot + } else { + snapshot, err = s.repositories.Snapshot(ctx, workspaceID, repositoryID, input.SnapshotID) + if err != nil { + return Run{}, err + } + } + if snapshot.SourceState != "available" || snapshot.CommitSHA == "" { + return Run{}, fault.New(fault.Conflict, "analysis.snapshot_unavailable", "Commit snapshot is not available for analysis") + } + if frozen == nil { + var profile Profile + if input.ProfileID == uuid.Nil { + profile, err = s.store.DefaultProfile(ctx, workspaceID) + } else { + profile, err = s.store.Profile(ctx, workspaceID, input.ProfileID) + } + if err != nil { + return Run{}, err + } + frozen = &frozenProfile{ + ID: profile.ID, + Version: fmt.Sprintf("v%d", profile.CurrentVersion), + DimensionKey: profile.DimensionKey, + Snapshot: append([]byte(nil), profile.Definition...), + } + } + + run := Run{ + ID: uuid.New(), WorkspaceID: workspaceID, RepositoryID: repositoryID, + SnapshotID: snapshot.ID, CommitSHA: snapshot.CommitSHA, RequestedBy: actor.UserID, + DimensionKey: frozen.DimensionKey, ProfileID: frozen.ID, ProfileVersion: frozen.Version, + ProfileSnapshot: append([]byte(nil), frozen.Snapshot...), AnalyzerVersion: scc.Version, + IdempotencyKey: idempotencyKey(workspaceID, repositoryID, snapshot.ID, frozen.ID, frozen.DimensionKey, frozen.Version, scc.Version, frozen.Snapshot), + Attempt: attempt, RerunOf: rerunOf, Status: "queued", Stage: "queued", + } + run, err = s.store.Create(ctx, run) + if err != nil { + return Run{}, err + } + return run, nil +} + +func (s *Service) Get(ctx context.Context, actor auth.Actor, workspaceID, runID uuid.UUID) (Run, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Run{}, err + } + + return s.store.Get(ctx, workspaceID, runID) +} + +func (s *Service) Report(ctx context.Context, actor auth.Actor, workspaceID, reportID uuid.UUID) (Report, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Report{}, err + } + + return s.store.Report(ctx, workspaceID, reportID) +} + +func (s *Service) List(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[Run], error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return pagination.Page[Run]{}, err + } + + return s.store.List(ctx, workspaceID, repositoryID, page) +} + +func (s *Service) Cancel(ctx context.Context, actor auth.Actor, workspaceID, runID uuid.UUID) error { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return err + } + workflowRunID, ok, err := s.store.Cancel(ctx, workspaceID, runID) + if err != nil || !ok { + return fault.New(fault.Conflict, "analysis.not_cancellable", "Analysis cannot be cancelled") + } + if workflowRunID != "" { + _ = s.canceller.CancelAnalysis(ctx, workflowRunID) + } + + return nil +} + +func (s *Service) Retry(ctx context.Context, actor auth.Actor, workspaceID, runID uuid.UUID) (Run, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Run{}, err + } + previous, err := s.store.Get(ctx, workspaceID, runID) + if err != nil { + return Run{}, err + } + if (previous.Status != "failed" && previous.Status != "cancelled") || !previous.Retryable { + return Run{}, fault.New(fault.Conflict, "analysis.not_retryable", "Analysis cannot be retried") + } + return s.create( + ctx, + actor, + workspaceID, + previous.RepositoryID, + CreateInput{SnapshotID: previous.SnapshotID, ProfileID: previous.ProfileID}, + previous.Attempt+1, + &previous.ID, + &frozenProfile{ID: previous.ProfileID, Version: previous.ProfileVersion, DimensionKey: previous.DimensionKey, Snapshot: previous.ProfileSnapshot}, + ) +} diff --git a/internal/analysis/biz/service_test.go b/internal/analysis/biz/service_test.go new file mode 100644 index 0000000..7fecf86 --- /dev/null +++ b/internal/analysis/biz/service_test.go @@ -0,0 +1,198 @@ +package biz + +import ( + "context" + "encoding/json" + "errors" + "testing" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/fuchencong/mooncode/pkg/analyzer/scc" + "github.com/google/uuid" +) + +type analysisAuthorizer struct { + err error +} + +func (a analysisAuthorizer) Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) { + return identity.Membership{Role: "member"}, a.err +} + +type analysisStore struct { + getCalled bool + getRun Run + created Run + profile Profile + cancelWorkflowRunID string +} + +func (s *analysisStore) Create(_ context.Context, run Run) (Run, error) { + s.created = run + + return run, nil +} +func (s *analysisStore) Get(context.Context, uuid.UUID, uuid.UUID) (Run, error) { + s.getCalled = true + + return s.getRun, nil +} +func (*analysisStore) List(context.Context, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[Run], error) { + return pagination.Page[Run]{}, nil +} +func (*analysisStore) LoadWork(context.Context, uuid.UUID) (WorkItem, error) { return WorkItem{}, nil } +func (*analysisStore) Start(context.Context, uuid.UUID) (Run, error) { return Run{}, nil } +func (*analysisStore) SetStage(context.Context, uuid.UUID, string) (bool, error) { + return true, nil +} +func (*analysisStore) Finish(context.Context, uuid.UUID, json.RawMessage, json.RawMessage, Execution) (Run, error) { + return Run{}, nil +} +func (*analysisStore) Fail(context.Context, uuid.UUID, Failure) error { return nil } +func (s *analysisStore) Cancel(context.Context, uuid.UUID, uuid.UUID) (string, bool, error) { + return s.cancelWorkflowRunID, true, nil +} +func (*analysisStore) Report(context.Context, uuid.UUID, uuid.UUID) (Report, error) { + return Report{}, nil +} +func (*analysisStore) ListProfiles(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Profile], error) { + return pagination.Page[Profile]{}, nil +} +func (s *analysisStore) Profile(context.Context, uuid.UUID, uuid.UUID) (Profile, error) { + return s.profile, nil +} +func (s *analysisStore) DefaultProfile(context.Context, uuid.UUID) (Profile, error) { + return s.profile, nil +} +func (*analysisStore) CreateProfile(context.Context, uuid.UUID, uuid.UUID, string, string, json.RawMessage) (Profile, error) { + return Profile{}, nil +} +func (*analysisStore) UpdateProfile(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, int32, string, string, json.RawMessage) (Profile, error) { + return Profile{}, nil +} +func (*analysisStore) ArchiveProfile(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (bool, error) { + return true, nil +} + +type analysisCanceller struct { + called *string +} + +func (c analysisCanceller) CancelAnalysis(_ context.Context, workflowRunID string) error { + if c.called != nil { + *c.called = workflowRunID + } + + return nil +} + +type analysisRepositories struct { + repository repository.Repository + snapshot repository.Snapshot +} + +func (r analysisRepositories) Get(context.Context, uuid.UUID, uuid.UUID) (repository.Repository, error) { + return r.repository, nil +} + +func (r analysisRepositories) Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (repository.Snapshot, error) { + return r.snapshot, nil +} + +func TestRetryAuthorizesWorkspaceBeforeLoadingRun(t *testing.T) { + want := errors.New("workspace permission denied") + store := &analysisStore{} + service := NewService(store, analysisAuthorizer{err: want}, nil, analysisCanceller{}) + + _, err := service.Retry(context.Background(), auth.Actor{UserID: uuid.New()}, uuid.New(), uuid.New()) + if !errors.Is(err, want) { + t.Fatalf("Retry() error = %v, want %v", err, want) + } + if store.getCalled { + t.Fatal("analysis run was loaded before workspace authorization") + } +} + +func TestCancelUsesWorkflowIDReturnedByAtomicCancellation(t *testing.T) { + store := &analysisStore{cancelWorkflowRunID: "current-hatchet-run"} + var cancelled string + service := NewService(store, analysisAuthorizer{}, nil, analysisCanceller{called: &cancelled}) + + if err := service.Cancel(context.Background(), auth.Actor{UserID: uuid.New()}, uuid.New(), uuid.New()); err != nil { + t.Fatal(err) + } + if cancelled != store.cancelWorkflowRunID { + t.Fatalf("cancelled workflow = %q, want %q", cancelled, store.cancelWorkflowRunID) + } +} + +func TestCreateFreezesSelectedSnapshotAndCommit(t *testing.T) { + workspaceID, repositoryID, snapshotID := uuid.New(), uuid.New(), uuid.New() + profileID := uuid.New() + store := &analysisStore{profile: Profile{ID: profileID, CurrentVersion: 1, DimensionKey: "code_scale", Definition: DefaultProfileDefinition()}} + repositories := analysisRepositories{ + repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, Status: "ready"}, + snapshot: repository.Snapshot{ID: snapshotID, RepositoryID: repositoryID, CommitSHA: "0123456789012345678901234567890123456789", SourceState: "available"}, + } + service := NewService(store, analysisAuthorizer{}, repositories, analysisCanceller{}) + + run, err := service.Create(context.Background(), auth.Actor{UserID: uuid.New()}, workspaceID, repositoryID, CreateInput{SnapshotID: snapshotID, ProfileID: profileID}) + if err != nil { + t.Fatal(err) + } + if run.SnapshotID != snapshotID || run.CommitSHA != repositories.snapshot.CommitSHA || store.created.CommitSHA != repositories.snapshot.CommitSHA { + t.Fatalf("analysis target was not frozen: %#v", run) + } + if run.Attempt != 1 || len(run.IdempotencyKey) != 64 || run.RerunOf != nil { + t.Fatalf("initial analysis idempotency metadata is invalid: %#v", run) + } + if run.ProfileID != profileID || run.ProfileVersion != "v1" || string(run.ProfileSnapshot) != string(store.profile.Definition) { + t.Fatalf("analysis profile was not frozen: %#v", run) + } +} + +func TestRetryKeepsOriginalSnapshot(t *testing.T) { + workspaceID, repositoryID, snapshotID := uuid.New(), uuid.New(), uuid.New() + commitSHA := "0123456789012345678901234567890123456789" + previousID := uuid.New() + profileID := uuid.New() + profileSnapshot := DefaultProfileDefinition() + store := &analysisStore{getRun: Run{ID: previousID, RepositoryID: repositoryID, SnapshotID: snapshotID, CommitSHA: commitSHA, DimensionKey: "code_scale", ProfileID: profileID, ProfileVersion: "v1", ProfileSnapshot: profileSnapshot, AnalyzerVersion: "scc-old", Status: "failed", Retryable: true, Attempt: 1}} + repositories := analysisRepositories{ + repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, Status: "ready"}, + snapshot: repository.Snapshot{ID: snapshotID, RepositoryID: repositoryID, CommitSHA: commitSHA, SourceState: "available"}, + } + service := NewService(store, analysisAuthorizer{}, repositories, analysisCanceller{}) + + run, err := service.Retry(context.Background(), auth.Actor{UserID: uuid.New()}, workspaceID, uuid.New()) + if err != nil { + t.Fatal(err) + } + if run.SnapshotID != snapshotID || run.CommitSHA != commitSHA { + t.Fatalf("retry changed analysis target: %#v", run) + } + if run.Attempt != 2 || run.RerunOf == nil || *run.RerunOf != previousID { + t.Fatalf("retry did not create the next attempt: %#v", run) + } + if run.ProfileID != profileID || run.ProfileVersion != "v1" || string(run.ProfileSnapshot) != string(profileSnapshot) { + t.Fatalf("retry changed profile snapshot: %#v", run) + } + if run.AnalyzerVersion != scc.Version { + t.Fatalf("retry analyzer version = %q, want current %q", run.AnalyzerVersion, scc.Version) + } +} + +func TestRetryRejectsNonRetryableFailure(t *testing.T) { + store := &analysisStore{getRun: Run{Status: "failed", Retryable: false}} + service := NewService(store, analysisAuthorizer{}, nil, analysisCanceller{}) + + _, err := service.Retry(context.Background(), auth.Actor{UserID: uuid.New()}, uuid.New(), uuid.New()) + problem, ok := fault.From(err) + if !ok || problem.Code() != "analysis.not_retryable" { + t.Fatalf("Retry() error = %v, want analysis.not_retryable", err) + } +} diff --git a/internal/analysis/biz/store.go b/internal/analysis/biz/store.go new file mode 100644 index 0000000..b9f7320 --- /dev/null +++ b/internal/analysis/biz/store.go @@ -0,0 +1,32 @@ +package biz + +import ( + "context" + "encoding/json" + + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" +) + +type Store interface { + Create(context.Context, Run) (Run, error) + Get(context.Context, uuid.UUID, uuid.UUID) (Run, error) + List(context.Context, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[Run], error) + LoadWork(context.Context, uuid.UUID) (WorkItem, error) + Start(context.Context, uuid.UUID) (Run, error) + SetStage(context.Context, uuid.UUID, string) (bool, error) + Finish(context.Context, uuid.UUID, json.RawMessage, json.RawMessage, Execution) (Run, error) + Fail(context.Context, uuid.UUID, Failure) error + Cancel(context.Context, uuid.UUID, uuid.UUID) (string, bool, error) + Report(context.Context, uuid.UUID, uuid.UUID) (Report, error) + ListProfiles(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Profile], error) + Profile(context.Context, uuid.UUID, uuid.UUID) (Profile, error) + DefaultProfile(context.Context, uuid.UUID) (Profile, error) + CreateProfile(context.Context, uuid.UUID, uuid.UUID, string, string, json.RawMessage) (Profile, error) + UpdateProfile(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, int32, string, string, json.RawMessage) (Profile, error) + ArchiveProfile(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (bool, error) +} + +type Canceller interface { + CancelAnalysis(context.Context, string) error +} diff --git a/internal/analysis/data/audit_integration_test.go b/internal/analysis/data/audit_integration_test.go new file mode 100644 index 0000000..0a57473 --- /dev/null +++ b/internal/analysis/data/audit_integration_test.go @@ -0,0 +1,102 @@ +//go:build integration + +package data + +import ( + "context" + "os" + "strings" + "testing" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" +) + +func TestCreateAnalysisPersistsAuditWithDispatch(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + actorID, workspaceID, repositoryID, snapshotID, profileID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New() + profileDefinition := analysis.DefaultProfileDefinition() + statements := []struct { + query string + args []any + }{ + {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{actorID}}, + {`INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Analysis audit',$2,$3)`, []any{workspaceID, "analysis-audit-" + workspaceID.String(), actorID}}, + {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, actorID}}, + {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, actorID}}, + {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", actorID}}, + {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{snapshotID, repositoryID, strings.Repeat("a", 40), "refs/mooncode/snapshots/" + snapshotID.String()}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatal(err) + } + } + + run := analysis.Run{ + ID: uuid.New(), + WorkspaceID: workspaceID, + RepositoryID: repositoryID, + SnapshotID: snapshotID, + CommitSHA: strings.Repeat("a", 40), + RequestedBy: actorID, + DimensionKey: "code_scale", + ProfileID: profileID, + ProfileVersion: "v1", + ProfileSnapshot: profileDefinition, + AnalyzerVersion: "integration", + IdempotencyKey: strings.Repeat("c", 64), + Attempt: 1, + Status: "queued", + } + store := NewStore(pool) + created, err := store.Create(ctx, run) + if err != nil { + t.Fatal(err) + } + duplicate := run + duplicate.ID = uuid.New() + deduplicated, err := store.Create(ctx, duplicate) + if err != nil { + t.Fatal(err) + } + if deduplicated.ID != created.ID { + t.Fatalf("duplicate analysis created run %s, want existing %s", deduplicated.ID, created.ID) + } + + var eventWorkspaceID, eventActorID uuid.UUID + var action, resourceType, metadata string + if err := pool.QueryRow(ctx, `SELECT workspace_id,actor_user_id,action,resource_type,metadata::text FROM audit_events WHERE resource_id=$1`, run.ID).Scan(&eventWorkspaceID, &eventActorID, &action, &resourceType, &metadata); err != nil { + t.Fatal(err) + } + if eventWorkspaceID != workspaceID || eventActorID != actorID || action != audit.ActionAnalysisCreated || resourceType != audit.ResourceAnalysisRun { + t.Fatalf("unexpected analysis audit identity: workspace=%s actor=%s action=%q resource=%q", eventWorkspaceID, eventActorID, action, resourceType) + } + if strings.Contains(metadata, run.CommitSHA) { + t.Fatalf("analysis audit metadata unexpectedly contains commit payload: %s", metadata) + } + + var dispatchCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM workflow_dispatches WHERE aggregate_type='analysis_run' AND aggregate_id=$1`, run.ID).Scan(&dispatchCount); err != nil { + t.Fatal(err) + } + if dispatchCount != 1 { + t.Fatalf("workflow dispatch count = %d, want 1", dispatchCount) + } + var matchingRuns int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM analysis_runs WHERE workspace_id=$1 AND idempotency_key=$2 AND attempt=1`, workspaceID, run.IdempotencyKey).Scan(&matchingRuns); err != nil { + t.Fatal(err) + } + if matchingRuns != 1 { + t.Fatalf("idempotent analysis run count = %d, want 1", matchingRuns) + } +} diff --git a/internal/analysis/data/failure_integration_test.go b/internal/analysis/data/failure_integration_test.go new file mode 100644 index 0000000..076ab05 --- /dev/null +++ b/internal/analysis/data/failure_integration_test.go @@ -0,0 +1,90 @@ +//go:build integration + +package data + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "strings" + "testing" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" +) + +func TestFailureAndCancellationPersistRetrySemantics(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + userID, workspaceID, repositoryID, snapshotID, profileID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New() + profileDefinition := analysis.DefaultProfileDefinition() + statements := []struct { + query string + args []any + }{ + {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{userID}}, + {`INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Analysis failure',$2,$3)`, []any{workspaceID, "analysis-failure-" + workspaceID.String(), userID}}, + {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, userID}}, + {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, userID}}, + {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", userID}}, + {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{snapshotID, repositoryID, strings.Repeat("a", 40), "refs/mooncode/snapshots/" + snapshotID.String()}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatal(err) + } + } + + insertRun := func(status string) uuid.UUID { + t.Helper() + id := uuid.New() + digest := sha256.Sum256(id[:]) + _, err := pool.Exec(ctx, `INSERT INTO analysis_runs (id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt,status) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,$10)`, id, workspaceID, repositoryID, snapshotID, strings.Repeat("a", 40), userID, profileID, profileDefinition, hex.EncodeToString(digest[:]), status) + if err != nil { + t.Fatal(err) + } + + return id + } + + store := NewStore(pool) + failedID := insertRun("running") + failure := analysis.Failure{Stage: "analyze", Code: "analysis.analyzer_failed", Message: "Analyzer execution failed", Retryable: true} + if err := store.Fail(ctx, failedID, failure); err != nil { + t.Fatal(err) + } + failed, err := store.Get(ctx, workspaceID, failedID) + if err != nil { + t.Fatal(err) + } + if failed.Status != "failed" || failed.FailedStage != failure.Stage || failed.ErrorCode != failure.Code || failed.ErrorMessage != failure.Message || !failed.Retryable { + t.Fatalf("unexpected persisted analysis failure: %+v", failed) + } + + cancelledID := insertRun("queued") + if _, err := pool.Exec(ctx, `UPDATE analysis_runs SET workflow_run_id='analysis-workflow-run' WHERE id=$1`, cancelledID); err != nil { + t.Fatal(err) + } + workflowRunID, ok, err := store.Cancel(ctx, workspaceID, cancelledID) + if err != nil || !ok { + t.Fatalf("Cancel() = (%v, %v)", ok, err) + } + if workflowRunID != "analysis-workflow-run" { + t.Fatalf("Cancel() workflow run ID = %q", workflowRunID) + } + cancelled, err := store.Get(ctx, workspaceID, cancelledID) + if err != nil { + t.Fatal(err) + } + if cancelled.Status != "cancelled" || !cancelled.Retryable || cancelled.FailedStage != "" || cancelled.ErrorCode != "" { + t.Fatalf("unexpected persisted cancellation: %+v", cancelled) + } +} diff --git a/internal/analysis/data/notification_integration_test.go b/internal/analysis/data/notification_integration_test.go new file mode 100644 index 0000000..7a09b0e --- /dev/null +++ b/internal/analysis/data/notification_integration_test.go @@ -0,0 +1,148 @@ +//go:build integration + +package data + +import ( + "context" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + "time" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" +) + +func TestFinishCreatesNotificationAndDispatchAtomically(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + userID, workspaceID, repositoryID, snapshotID, runID, channelID, unsubscribedChannelID, profileID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New() + profileDefinition := analysis.DefaultProfileDefinition() + statements := []struct { + query string + args []any + }{ + {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{userID}}, + {`INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Notification test',$2,$3)`, []any{workspaceID, "notification-" + workspaceID.String(), userID}}, + {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, userID}}, + {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, userID}}, + {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", userID}}, + {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,author_name,authored_at,title,source_state) VALUES ($1,$2,$3,$4,$5,'Ada Lovelace',TIMESTAMPTZ '2026-07-01T02:03:04Z','Trace metadata','available')`, []any{snapshotID, repositoryID, "1234567890abcdef", "refs/heads/main", "refs/mooncode/snapshots/" + snapshotID.String()}}, + {`UPDATE repositories SET current_snapshot_id=$2 WHERE id=$1`, []any{repositoryID, snapshotID}}, + {`INSERT INTO analysis_runs (id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt,status,stage,started_at) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,'running','analyze',clock_timestamp() - interval '250 milliseconds')`, []any{runID, workspaceID, repositoryID, snapshotID, "1234567890abcdef", userID, profileID, profileDefinition, strings.Repeat("d", 64)}}, + {`INSERT INTO channels (id,workspace_id,type,name,enabled,secret_ciphertext,secret_nonce,key_version,config) VALUES ($1,$2,'feishu','team',true,$3,$4,1,$5)`, []any{channelID, workspaceID, []byte("ciphertext"), []byte("nonce"), json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"}}`)}}, + {`INSERT INTO channels (id,workspace_id,type,name,enabled,secret_ciphertext,secret_nonce,key_version,config) VALUES ($1,$2,'feishu','unsubscribed',true,$3,$4,1,$5)`, []any{unsubscribedChannelID, workspaceID, []byte("ciphertext"), []byte("nonce"), json.RawMessage(`{"values":{"app_id":"app","receive_id":"other-chat"}}`)}}, + {`INSERT INTO channel_subscriptions (id,workspace_id,channel_id,event_type) VALUES ($1,$2,$3,'analysis.succeeded')`, []any{uuid.New(), workspaceID, channelID}}, + {`INSERT INTO channel_subscriptions (id,workspace_id,channel_id,event_type) VALUES ($1,$2,$3,'analysis.started')`, []any{uuid.New(), workspaceID, channelID}}, + {`INSERT INTO channel_subscriptions (id,workspace_id,channel_id,event_type) VALUES ($1,$2,$3,'analysis.failed')`, []any{uuid.New(), workspaceID, channelID}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatal(err) + } + } + + store := NewStore(pool) + result := json.RawMessage(`{"summary":{"files":1,"code":10},"languages":[{"name":"Go"}]}`) + rawArtifact := json.RawMessage(`[{"Name":"Go","Code":10}]`) + run, err := store.Finish(ctx, runID, result, rawArtifact, analysis.Execution{Environment: "integration/test"}) + if err != nil { + t.Fatal(err) + } + if run.Status != "succeeded" || run.Report == nil || run.Report.CommitSHA != "1234567890abcdef" { + t.Fatalf("unexpected completed run %#v", run) + } + wantAuthoredAt := time.Date(2026, time.July, 1, 2, 3, 4, 0, time.UTC) + if run.Report.SourceRef != "refs/heads/main" || run.Report.CommitAuthor != "Ada Lovelace" || run.Report.CommitTitle != "Trace metadata" { + t.Fatalf("unexpected report commit metadata: %#v", run.Report) + } + if run.Report.CommitAuthoredAt == nil || !run.Report.CommitAuthoredAt.Equal(wantAuthoredAt) { + t.Fatalf("report authored time = %v, want %v", run.Report.CommitAuthoredAt, wantAuthoredAt) + } + if run.Report.ExecutionEnvironment != "integration/test" || run.Report.DurationMS < 0 { + t.Fatalf("unexpected report execution metadata: %#v", run.Report) + } + var frozenProfile analysis.ProfileDefinition + if err := json.Unmarshal(run.Report.ProfileSnapshot, &frozenProfile); err != nil { + t.Fatal(err) + } + if run.ProfileID != profileID || run.Report.ProfileID != profileID || run.ProfileVersion != "v1" || run.Report.ProfileVersion != run.ProfileVersion || frozenProfile.TimeoutSeconds != 300 { + t.Fatalf("run/report profile snapshot differs: run=%#v report=%#v", run, run.Report) + } + if run.StartedAt == nil || run.FinishedAt == nil || !run.Report.StartedAt.Equal(*run.StartedAt) || !run.Report.FinishedAt.Equal(*run.FinishedAt) { + t.Fatalf("run/report timestamps differ: run=%#v report=%#v", run, run.Report) + } + var storedRaw []byte + if err := pool.QueryRow(ctx, `SELECT raw_artifact FROM analysis_reports WHERE analysis_run_id=$1`, runID).Scan(&storedRaw); err != nil { + t.Fatal(err) + } + var storedArtifact, expectedArtifact any + if err := json.Unmarshal(storedRaw, &storedArtifact); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(rawArtifact, &expectedArtifact); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(storedArtifact, expectedArtifact) { + t.Fatalf("raw artifact = %s, want %s", storedRaw, rawArtifact) + } + var notificationCount, dispatchCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM notifications WHERE analysis_run_id=$1 AND channel_id=$2 AND status='queued'`, runID, channelID).Scan(¬ificationCount); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM workflow_dispatches WHERE aggregate_type='notification' AND workflow_name='notification-delivery' AND aggregate_id IN (SELECT id FROM notifications WHERE analysis_run_id=$1)`, runID).Scan(&dispatchCount); err != nil { + t.Fatal(err) + } + if notificationCount != 1 || dispatchCount != 1 { + t.Fatalf("expected one notification and dispatch, got %d and %d", notificationCount, dispatchCount) + } + var unsubscribedCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM notifications WHERE analysis_run_id=$1 AND channel_id=$2`, runID, unsubscribedChannelID).Scan(&unsubscribedCount); err != nil { + t.Fatal(err) + } + if unsubscribedCount != 0 { + t.Fatalf("unsubscribed channel received %d notifications", unsubscribedCount) + } + + startedRunID := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO analysis_runs (id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt,status) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,'queued')`, startedRunID, workspaceID, repositoryID, snapshotID, "1234567890abcdef", userID, profileID, profileDefinition, strings.Repeat("e", 64)); err != nil { + t.Fatal(err) + } + started, err := store.Start(ctx, startedRunID) + if err != nil || started.Status != "running" { + t.Fatalf("Start() = (%#v, %v)", started, err) + } + + failedRunID := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO analysis_runs (id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt,status,stage,started_at) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,'running','analyze',now())`, failedRunID, workspaceID, repositoryID, snapshotID, "1234567890abcdef", userID, profileID, profileDefinition, strings.Repeat("f", 64)); err != nil { + t.Fatal(err) + } + failure := analysis.Failure{Stage: "analyze", Code: "analysis.analyzer_failed", Message: "Analyzer execution failed", Retryable: true} + if err := store.Fail(ctx, failedRunID, failure); err != nil { + t.Fatal(err) + } + + for event, eventRunID := range map[string]uuid.UUID{ + "analysis.started": startedRunID, + "analysis.failed": failedRunID, + } { + var notifications, dispatches int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM notifications WHERE analysis_run_id=$1 AND channel_id=$2 AND event_type=$3`, eventRunID, channelID, event).Scan(¬ifications); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM workflow_dispatches WHERE aggregate_type='notification' AND aggregate_id IN (SELECT id FROM notifications WHERE analysis_run_id=$1 AND event_type=$2)`, eventRunID, event).Scan(&dispatches); err != nil { + t.Fatal(err) + } + if notifications != 1 || dispatches != 1 { + t.Fatalf("event %q notifications/dispatches = %d/%d, want 1/1", event, notifications, dispatches) + } + } +} diff --git a/internal/analysis/data/options.go b/internal/analysis/data/options.go new file mode 100644 index 0000000..6a16753 --- /dev/null +++ b/internal/analysis/data/options.go @@ -0,0 +1,11 @@ +package data + +type Option func(*Store) + +func WithMaxConcurrentRunsPerWorkspace(limit int64) Option { + return func(store *Store) { + if limit > 0 { + store.maxConcurrentPerWorkspace = limit + } + } +} diff --git a/internal/analysis/data/profile_integration_test.go b/internal/analysis/data/profile_integration_test.go new file mode 100644 index 0000000..6437cb7 --- /dev/null +++ b/internal/analysis/data/profile_integration_test.go @@ -0,0 +1,82 @@ +//go:build integration + +package data + +import ( + "context" + "encoding/json" + "os" + "testing" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" +) + +func TestAnalysisProfileVersionsAreImmutableAndLastProfileIsRetained(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + actorID, workspaceID := uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, actorID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Profile integration',$2,$3)`, workspaceID, "profile-"+workspaceID.String(), actorID); err != nil { + t.Fatal(err) + } + + store := NewStore(pool) + first, err := store.CreateProfile(ctx, workspaceID, actorID, "Code scale", "code_scale", analysis.DefaultProfileDefinition()) + if err != nil { + t.Fatal(err) + } + updatedDefinition := json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":120,"analyzer":{"key":"scc","required":true}}`) + updated, err := store.UpdateProfile(ctx, workspaceID, actorID, first.ID, first.CurrentVersion, "Code scale", "code_scale", updatedDefinition) + if err != nil { + t.Fatal(err) + } + var definition analysis.ProfileDefinition + if err := json.Unmarshal(updated.Definition, &definition); err != nil { + t.Fatal(err) + } + if updated.CurrentVersion != 2 || definition.TimeoutSeconds != 120 { + t.Fatalf("unexpected profile version: %#v", updated) + } + var versions int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM analysis_profile_versions WHERE profile_id=$1`, first.ID).Scan(&versions); err != nil { + t.Fatal(err) + } + if versions != 2 { + t.Fatalf("profile version count = %d, want 2", versions) + } + if _, err := store.UpdateProfile(ctx, workspaceID, actorID, first.ID, 1, "stale", "code_scale", updatedDefinition); err == nil { + t.Fatal("expected stale profile version to fail") + } else if problem, ok := fault.From(err); !ok || problem.Code() != "analysis.profile_version_stale" { + t.Fatalf("stale update error = %v", err) + } + if _, err := store.ArchiveProfile(ctx, workspaceID, actorID, first.ID); err == nil { + t.Fatal("expected last active profile archive to fail") + } else if problem, ok := fault.From(err); !ok || problem.Code() != "analysis.profile_last_active" { + t.Fatalf("last profile archive error = %v", err) + } + second, err := store.CreateProfile(ctx, workspaceID, actorID, "Short", "code_scale", updatedDefinition) + if err != nil { + t.Fatal(err) + } + if archived, err := store.ArchiveProfile(ctx, workspaceID, actorID, second.ID); err != nil || !archived { + t.Fatalf("ArchiveProfile() = (%v, %v)", archived, err) + } + var auditEvents int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_events WHERE workspace_id=$1 AND actor_user_id=$2 AND resource_type=$3 AND resource_id IN ($4,$5)`, workspaceID, actorID, audit.ResourceAnalysisProfile, first.ID, second.ID).Scan(&auditEvents); err != nil { + t.Fatal(err) + } + if auditEvents != 4 { + t.Fatalf("analysis profile audit event count = %d, want 4", auditEvents) + } +} diff --git a/internal/analysis/data/profile_store.go b/internal/analysis/data/profile_store.go new file mode 100644 index 0000000..8cd2bf5 --- /dev/null +++ b/internal/analysis/data/profile_store.go @@ -0,0 +1,197 @@ +package data + +import ( + "context" + "encoding/json" + "errors" + "time" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + "github.com/fuchencong/mooncode/internal/data/pagecursor" + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (s *Store) ListProfiles(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[analysis.Profile], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListAnalysisProfiles(ctx, sqlc.ListAnalysisProfilesParams{ + WorkspaceID: workspaceID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[analysis.Profile]{}, err + } + items := make([]analysis.Profile, 0, len(rows)) + for _, row := range rows { + items = append(items, analysis.Profile{ + ID: row.ID, WorkspaceID: row.WorkspaceID, Name: row.Name, CurrentVersion: row.CurrentVersion, + DimensionKey: row.DimensionKey, Definition: row.Definition, CreatedBy: row.CreatedBy, + CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, VersionCreatedAt: row.VersionCreatedAt.Time, + }) + } + + return pagination.Build(items, page.Limit, func(item analysis.Profile) (time.Time, uuid.UUID) { + return item.CreatedAt, item.ID + }), nil +} + +func (s *Store) Profile(ctx context.Context, workspaceID, profileID uuid.UUID) (analysis.Profile, error) { + row, err := s.queries.GetAnalysisProfile(ctx, sqlc.GetAnalysisProfileParams{ID: profileID, WorkspaceID: workspaceID}) + if errors.Is(err, pgx.ErrNoRows) { + return analysis.Profile{}, fault.New(fault.NotFound, "analysis.profile_not_found", "Analysis profile was not found") + } + if err != nil { + return analysis.Profile{}, err + } + + return profileFromGet(row), nil +} + +func (s *Store) DefaultProfile(ctx context.Context, workspaceID uuid.UUID) (analysis.Profile, error) { + row, err := s.queries.GetDefaultAnalysisProfile(ctx, workspaceID) + if errors.Is(err, pgx.ErrNoRows) { + return analysis.Profile{}, fault.New(fault.Conflict, "analysis.profile_required", "Workspace has no available analysis profile") + } + if err != nil { + return analysis.Profile{}, err + } + + return analysis.Profile{ + ID: row.ID, WorkspaceID: row.WorkspaceID, Name: row.Name, CurrentVersion: row.CurrentVersion, + DimensionKey: row.DimensionKey, Definition: row.Definition, CreatedBy: row.CreatedBy, + CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, VersionCreatedAt: row.VersionCreatedAt.Time, + }, nil +} + +func (s *Store) CreateProfile(ctx context.Context, workspaceID, actorID uuid.UUID, name, dimensionKey string, definition json.RawMessage) (analysis.Profile, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return analysis.Profile{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + queries := s.queries.WithTx(tx) + profileID := uuid.New() + row, err := queries.CreateAnalysisProfile(ctx, sqlc.CreateAnalysisProfileParams{ + ID: profileID, WorkspaceID: workspaceID, Name: name, CreatedBy: actorID, + }) + if err != nil { + return analysis.Profile{}, err + } + version, err := queries.CreateAnalysisProfileVersion(ctx, sqlc.CreateAnalysisProfileVersionParams{ + ID: uuid.New(), WorkspaceID: workspaceID, ProfileID: profileID, Version: 1, + DimensionKey: dimensionKey, Definition: definition, CreatedBy: actorID, + }) + if err != nil { + return analysis.Profile{}, err + } + if err = audit.Record(ctx, queries, audit.Event{ + WorkspaceID: workspaceID, ActorUserID: actorID, Action: audit.ActionAnalysisProfileCreated, + Resource: audit.ResourceAnalysisProfile, ResourceID: profileID, Metadata: map[string]any{"version": 1, "dimensionKey": dimensionKey}, + }); err != nil { + return analysis.Profile{}, err + } + if err = tx.Commit(ctx); err != nil { + return analysis.Profile{}, err + } + + return profileFromRows(row, version), nil +} + +func (s *Store) UpdateProfile(ctx context.Context, workspaceID, actorID, profileID uuid.UUID, currentVersion int32, name, dimensionKey string, definition json.RawMessage) (analysis.Profile, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return analysis.Profile{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + queries := s.queries.WithTx(tx) + row, err := queries.AdvanceAnalysisProfile(ctx, sqlc.AdvanceAnalysisProfileParams{ + ID: profileID, WorkspaceID: workspaceID, Name: name, CurrentVersion: currentVersion, + }) + if errors.Is(err, pgx.ErrNoRows) { + return analysis.Profile{}, fault.New(fault.Conflict, "analysis.profile_version_stale", "Analysis profile has changed; reload and try again") + } + if err != nil { + return analysis.Profile{}, err + } + version, err := queries.CreateAnalysisProfileVersion(ctx, sqlc.CreateAnalysisProfileVersionParams{ + ID: uuid.New(), WorkspaceID: workspaceID, ProfileID: profileID, Version: row.CurrentVersion, + DimensionKey: dimensionKey, Definition: definition, CreatedBy: actorID, + }) + if err != nil { + return analysis.Profile{}, err + } + if err = audit.Record(ctx, queries, audit.Event{ + WorkspaceID: workspaceID, ActorUserID: actorID, Action: audit.ActionAnalysisProfileVersionCreated, + Resource: audit.ResourceAnalysisProfile, ResourceID: profileID, Metadata: map[string]any{"version": row.CurrentVersion, "dimensionKey": dimensionKey}, + }); err != nil { + return analysis.Profile{}, err + } + if err = tx.Commit(ctx); err != nil { + return analysis.Profile{}, err + } + + return profileFromRows(row, version), nil +} + +func (s *Store) ArchiveProfile(ctx context.Context, workspaceID, actorID, profileID uuid.UUID) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + queries := s.queries.WithTx(tx) + active, err := queries.LockActiveAnalysisProfiles(ctx, workspaceID) + if err != nil { + return false, err + } + found := false + for _, id := range active { + if id == profileID { + found = true + break + } + } + if !found { + return false, nil + } + if len(active) <= 1 { + return false, fault.New(fault.Conflict, "analysis.profile_last_active", "Workspace must keep at least one active analysis profile") + } + count, err := queries.ArchiveAnalysisProfile(ctx, sqlc.ArchiveAnalysisProfileParams{ID: profileID, WorkspaceID: workspaceID}) + if err != nil || count == 0 { + return false, err + } + if err = audit.Record(ctx, queries, audit.Event{ + WorkspaceID: workspaceID, ActorUserID: actorID, Action: audit.ActionAnalysisProfileArchived, + Resource: audit.ResourceAnalysisProfile, ResourceID: profileID, + }); err != nil { + return false, err + } + if err = tx.Commit(ctx); err != nil { + return false, err + } + + return true, nil +} + +func profileFromGet(row sqlc.GetAnalysisProfileRow) analysis.Profile { + return analysis.Profile{ + ID: row.ID, WorkspaceID: row.WorkspaceID, Name: row.Name, CurrentVersion: row.CurrentVersion, + DimensionKey: row.DimensionKey, Definition: row.Definition, CreatedBy: row.CreatedBy, + CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, VersionCreatedAt: row.VersionCreatedAt.Time, + } +} + +func profileFromRows(profile sqlc.AnalysisProfile, version sqlc.AnalysisProfileVersion) analysis.Profile { + return analysis.Profile{ + ID: profile.ID, WorkspaceID: profile.WorkspaceID, Name: profile.Name, CurrentVersion: profile.CurrentVersion, + DimensionKey: version.DimensionKey, Definition: version.Definition, CreatedBy: profile.CreatedBy, + CreatedAt: profile.CreatedAt.Time, UpdatedAt: profile.UpdatedAt.Time, VersionCreatedAt: version.CreatedAt.Time, + } +} diff --git a/internal/analysis/data/quota_integration_test.go b/internal/analysis/data/quota_integration_test.go new file mode 100644 index 0000000..f45dd6b --- /dev/null +++ b/internal/analysis/data/quota_integration_test.go @@ -0,0 +1,69 @@ +//go:build integration + +package data + +import ( + "context" + "os" + "strings" + "testing" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/fuchencong/mooncode/pkg/analyzer/scc" + "github.com/google/uuid" +) + +func TestAnalysisConcurrencyQuotaPreservesIdempotency(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + userID, workspaceID, repositoryID, snapshotID, profileID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New() + profileDefinition := analysis.DefaultProfileDefinition() + statements := []struct { + query string + args []any + }{ + {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{userID}}, + {`INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Analysis quota',$2,$3)`, []any{workspaceID, "analysis-quota-" + workspaceID.String(), userID}}, + {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, userID}}, + {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, userID}}, + {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", userID}}, + {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{snapshotID, repositoryID, strings.Repeat("a", 40), "refs/mooncode/snapshots/" + snapshotID.String()}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatal(err) + } + } + store := NewStore(pool, WithMaxConcurrentRunsPerWorkspace(1)) + newRun := func(key string) analysis.Run { + return analysis.Run{ + ID: uuid.New(), WorkspaceID: workspaceID, RepositoryID: repositoryID, SnapshotID: snapshotID, + CommitSHA: strings.Repeat("a", 40), RequestedBy: userID, DimensionKey: "code_scale", + ProfileID: profileID, ProfileVersion: "v1", ProfileSnapshot: profileDefinition, + AnalyzerVersion: scc.Version, IdempotencyKey: key, Attempt: 1, Status: "queued", Stage: "queued", + } + } + firstInput := newRun(strings.Repeat("b", 64)) + first, err := store.Create(ctx, firstInput) + if err != nil { + t.Fatal(err) + } + duplicateInput := firstInput + duplicateInput.ID = uuid.New() + duplicate, err := store.Create(ctx, duplicateInput) + if err != nil || duplicate.ID != first.ID { + t.Fatalf("duplicate Create() = (%#v, %v), want run %s", duplicate, err, first.ID) + } + if _, err := store.Create(ctx, newRun(strings.Repeat("c", 64))); err == nil { + t.Fatal("expected analysis concurrency quota error") + } else if problem, ok := fault.From(err); !ok || problem.Code() != "analysis.workspace_concurrency_exceeded" { + t.Fatalf("quota error = %v", err) + } +} diff --git a/internal/analysis/data/store.go b/internal/analysis/data/store.go new file mode 100644 index 0000000..b74d843 --- /dev/null +++ b/internal/analysis/data/store.go @@ -0,0 +1,427 @@ +package data + +import ( + "context" + "encoding/json" + "errors" + "time" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + channel "github.com/fuchencong/mooncode/internal/channel/biz" + "github.com/fuchencong/mooncode/internal/data/pagecursor" + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + workflow "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool + queries *sqlc.Queries + maxConcurrentPerWorkspace int64 +} + +func NewStore(pool *pgxpool.Pool, options ...Option) *Store { + store := &Store{pool: pool, queries: sqlc.New(pool), maxConcurrentPerWorkspace: 10} + for _, option := range options { + option(store) + } + + return store +} + +func (s *Store) Create(ctx context.Context, run analysis.Run) (analysis.Run, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return analysis.Run{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + if _, err = q.LockWorkspaceQuota(ctx, run.WorkspaceID); err != nil { + return analysis.Run{}, err + } + existing, err := q.GetAnalysisRunByAttempt(ctx, sqlc.GetAnalysisRunByAttemptParams{ + WorkspaceID: run.WorkspaceID, IdempotencyKey: run.IdempotencyKey, Attempt: run.Attempt, + }) + if err == nil { + return mapRun(existing), nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return analysis.Run{}, err + } + sourceState, err := q.LockAnalysisSnapshot(ctx, sqlc.LockAnalysisSnapshotParams{ + ID: run.SnapshotID, RepositoryID: run.RepositoryID, WorkspaceID: run.WorkspaceID, + }) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && sourceState != "available") { + return analysis.Run{}, fault.New(fault.Conflict, "analysis.snapshot_unavailable", "Commit snapshot is not available for analysis") + } + if err != nil { + return analysis.Run{}, err + } + active, err := q.CountActiveAnalysisRuns(ctx, run.WorkspaceID) + if err != nil { + return analysis.Run{}, err + } + if active >= s.maxConcurrentPerWorkspace { + return analysis.Run{}, fault.New(fault.Conflict, "analysis.workspace_concurrency_exceeded", "Workspace analysis concurrency quota has been reached") + } + runOf := uuid.NullUUID{} + if run.RerunOf != nil { + runOf = uuid.NullUUID{UUID: *run.RerunOf, Valid: true} + } + row, err := q.CreateAnalysisRun(ctx, sqlc.CreateAnalysisRunParams{ + ID: run.ID, WorkspaceID: run.WorkspaceID, RepositoryID: run.RepositoryID, + SnapshotID: run.SnapshotID, CommitSha: run.CommitSHA, RequestedBy: run.RequestedBy, + DimensionKey: run.DimensionKey, ProfileID: run.ProfileID, ProfileVersion: run.ProfileVersion, + ProfileSnapshot: run.ProfileSnapshot, AnalyzerVersion: run.AnalyzerVersion, + IdempotencyKey: run.IdempotencyKey, Attempt: run.Attempt, RerunOf: runOf, + }) + if err != nil { + return analysis.Run{}, err + } + dispatch, err := workflow.NewDispatch( + workflow.AggregateAnalysisRun, + run.ID, + workflow.WorkflowAnalysisRun, + workflow.Payload{AggregateID: run.ID, RepositoryID: run.RepositoryID}, + ) + if err != nil { + return analysis.Run{}, err + } + if _, err = q.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{ID: dispatch.ID, AggregateType: dispatch.AggregateType, AggregateID: dispatch.AggregateID, WorkflowName: dispatch.WorkflowName, Payload: dispatch.Payload}); err != nil { + return analysis.Run{}, err + } + if err = audit.Record(ctx, q, audit.Event{ + WorkspaceID: run.WorkspaceID, + ActorUserID: run.RequestedBy, + Action: audit.ActionAnalysisCreated, + Resource: audit.ResourceAnalysisRun, + ResourceID: run.ID, + Metadata: map[string]any{ + "repositoryId": run.RepositoryID, + "snapshotId": run.SnapshotID, + "dimensionKey": run.DimensionKey, + "attempt": run.Attempt, + }, + }); err != nil { + return analysis.Run{}, err + } + if err = tx.Commit(ctx); err != nil { + return analysis.Run{}, err + } + + return mapRun(row), nil +} + +func (s *Store) Get(ctx context.Context, workspaceID, id uuid.UUID) (analysis.Run, error) { + row, err := s.queries.GetAnalysisRun(ctx, sqlc.GetAnalysisRunParams{ID: id, WorkspaceID: workspaceID}) + if err != nil { + return analysis.Run{}, err + } + + return s.attachReport(ctx, mapRun(row)) +} + +func (s *Store) List(ctx context.Context, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[analysis.Run], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListAnalysisRuns(ctx, sqlc.ListAnalysisRunsParams{ + RepositoryID: repositoryID, + WorkspaceID: workspaceID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[analysis.Run]{}, err + } + items := make([]analysis.Run, 0, len(rows)) + for _, row := range rows { + item, attachErr := s.attachReport(ctx, mapRun(row)) + if attachErr != nil { + return pagination.Page[analysis.Run]{}, attachErr + } + items = append(items, item) + } + return pagination.Build(items, page.Limit, func(item analysis.Run) (time.Time, uuid.UUID) { + return item.CreatedAt, item.ID + }), nil +} + +func (s *Store) LoadWork(ctx context.Context, id uuid.UUID) (analysis.WorkItem, error) { + row, err := s.queries.GetAnalysisRunWork(ctx, id) + + return analysis.WorkItem{Run: mapRun(row), CommitSHA: row.CommitSha}, err +} + +func (s *Store) Start(ctx context.Context, id uuid.UUID) (analysis.Run, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return analysis.Run{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + row, err := q.StartAnalysisRun(ctx, id) + if err != nil { + return analysis.Run{}, err + } + if err = enqueueAnalysisNotifications(ctx, q, row, channel.EventAnalysisStarted); err != nil { + return analysis.Run{}, err + } + if err = tx.Commit(ctx); err != nil { + return analysis.Run{}, err + } + + return mapRun(row), nil +} +func (s *Store) SetStage(ctx context.Context, id uuid.UUID, stage string) (bool, error) { + count, err := s.queries.SetAnalysisRunStage(ctx, sqlc.SetAnalysisRunStageParams{ID: id, Stage: stage}) + + return count == 1, err +} +func (s *Store) Finish(ctx context.Context, id uuid.UUID, result, rawArtifact json.RawMessage, execution analysis.Execution) (analysis.Run, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return analysis.Run{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + work, err := q.GetAnalysisRunWork(ctx, id) + if err != nil { + return analysis.Run{}, err + } + if !work.StartedAt.Valid { + return analysis.Run{}, errors.New("analysis run has no start time") + } + snapshot, err := q.GetSnapshot(ctx, sqlc.GetSnapshotParams{ + ID: work.SnapshotID, + RepositoryID: work.RepositoryID, + }) + if err != nil { + return analysis.Run{}, err + } + finishedAt, err := q.AnalysisClock(ctx) + if err != nil { + return analysis.Run{}, err + } + if !finishedAt.Valid { + return analysis.Run{}, errors.New("analysis clock returned no time") + } + durationMS := finishedAt.Time.Sub(work.StartedAt.Time).Milliseconds() + if durationMS < 0 { + durationMS = 0 + } + if len(rawArtifact) == 0 { + rawArtifact = result + } + reportRow, err := q.CreateAnalysisReport(ctx, sqlc.CreateAnalysisReportParams{ + ID: uuid.New(), + AnalysisRunID: work.ID, + WorkspaceID: work.WorkspaceID, + RepositoryID: work.RepositoryID, + SnapshotID: work.SnapshotID, + CommitSha: work.CommitSha, + SourceRef: snapshot.SourceRef, + CommitAuthorName: snapshot.AuthorName, + CommitAuthoredAt: snapshot.AuthoredAt, + CommitTitle: snapshot.Title, + DimensionKey: work.DimensionKey, + ProfileID: work.ProfileID, + ProfileVersion: work.ProfileVersion, + ProfileSnapshot: work.ProfileSnapshot, + AnalyzerVersion: work.AnalyzerVersion, + ExecutionEnvironment: execution.Environment, + StartedAt: work.StartedAt, + FinishedAt: finishedAt, + DurationMs: durationMS, + Result: result, + RawArtifact: rawArtifact, + }) + if err != nil { + return analysis.Run{}, err + } + row, err := q.FinishAnalysisRun(ctx, sqlc.FinishAnalysisRunParams{ + ID: id, + ReportID: uuid.NullUUID{UUID: reportRow.ID, Valid: true}, + FinishedAt: finishedAt, + }) + if err != nil { + return analysis.Run{}, err + } + if err = enqueueAnalysisNotifications(ctx, q, row, channel.EventAnalysisSucceeded); err != nil { + return analysis.Run{}, err + } + if err = tx.Commit(ctx); err != nil { + return analysis.Run{}, err + } + + run := mapRun(row) + report := mapReport(reportRow) + run.Report = &report + + return run, nil +} +func (s *Store) Fail(ctx context.Context, id uuid.UUID, failure analysis.Failure) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + row, err := q.FailAnalysisRun(ctx, sqlc.FailAnalysisRunParams{ + ID: id, + Stage: failure.Stage, + ErrorCode: text(failure.Code), + ErrorMessage: text(failure.Message), + Retryable: failure.Retryable, + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if err = enqueueAnalysisNotifications(ctx, q, row, channel.EventAnalysisFailed); err != nil { + return err + } + + return tx.Commit(ctx) +} +func (s *Store) Cancel(ctx context.Context, workspaceID, id uuid.UUID) (string, bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return "", false, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + workflowRunID, err := q.CancelAnalysisRun(ctx, sqlc.CancelAnalysisRunParams{ID: id, WorkspaceID: workspaceID}) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, err + } + if err = q.CancelWorkflowDispatch(ctx, sqlc.CancelWorkflowDispatchParams{AggregateType: workflow.AggregateAnalysisRun, AggregateID: id}); err != nil { + return "", false, err + } + if err = tx.Commit(ctx); err != nil { + return "", false, err + } + + return workflowRunID.String, true, nil +} + +func (s *Store) Report(ctx context.Context, workspaceID, id uuid.UUID) (analysis.Report, error) { + row, err := s.queries.GetAnalysisReport(ctx, sqlc.GetAnalysisReportParams{ID: id, WorkspaceID: workspaceID}) + + return mapReport(row), err +} + +func (s *Store) attachReport(ctx context.Context, run analysis.Run) (analysis.Run, error) { + if run.ReportID == nil { + return run, nil + } + row, err := s.queries.GetAnalysisReportByRun(ctx, run.ID) + if err != nil { + return analysis.Run{}, err + } + report := mapReport(row) + run.Report = &report + + return run, nil +} + +func mapRun(row sqlc.AnalysisRun) analysis.Run { + run := analysis.Run{ID: row.ID, WorkspaceID: row.WorkspaceID, RepositoryID: row.RepositoryID, SnapshotID: row.SnapshotID, CommitSHA: row.CommitSha, RequestedBy: row.RequestedBy, DimensionKey: row.DimensionKey, ProfileID: row.ProfileID, ProfileVersion: row.ProfileVersion, ProfileSnapshot: row.ProfileSnapshot, AnalyzerVersion: row.AnalyzerVersion, IdempotencyKey: row.IdempotencyKey, Attempt: row.Attempt, Status: row.Status, Stage: row.Stage, WorkflowRunID: row.WorkflowRunID.String, FailedStage: row.FailedStage.String, ErrorCode: row.ErrorCode.String, ErrorMessage: row.ErrorMessage.String, Retryable: row.Retryable, CreatedAt: row.CreatedAt.Time, StartedAt: optionalTime(row.StartedAt), FinishedAt: optionalTime(row.FinishedAt)} + if row.RerunOf.Valid { + id := row.RerunOf.UUID + run.RerunOf = &id + } + if row.ReportID.Valid { + id := row.ReportID.UUID + run.ReportID = &id + } + + return run +} + +func mapReport(row sqlc.AnalysisReport) analysis.Report { + return analysis.Report{ + ID: row.ID, + AnalysisRunID: row.AnalysisRunID, + WorkspaceID: row.WorkspaceID, + RepositoryID: row.RepositoryID, + SnapshotID: row.SnapshotID, + CommitSHA: row.CommitSha, + SourceRef: row.SourceRef, + CommitAuthor: row.CommitAuthorName.String, + CommitAuthoredAt: optionalTime(row.CommitAuthoredAt), + CommitTitle: row.CommitTitle.String, + DimensionKey: row.DimensionKey, + ProfileID: row.ProfileID, + ProfileVersion: row.ProfileVersion, + ProfileSnapshot: row.ProfileSnapshot, + AnalyzerVersion: row.AnalyzerVersion, + ExecutionEnvironment: row.ExecutionEnvironment, + StartedAt: row.StartedAt.Time, + FinishedAt: row.FinishedAt.Time, + DurationMS: row.DurationMs, + Result: row.Result, + RawArtifact: row.RawArtifact, + CreatedAt: row.CreatedAt.Time, + } +} +func text(value string) pgtype.Text { return pgtype.Text{String: value, Valid: value != ""} } +func optionalTime(value pgtype.Timestamptz) *time.Time { + if !value.Valid { + return nil + } + result := value.Time + return &result +} + +func enqueueAnalysisNotifications(ctx context.Context, queries *sqlc.Queries, run sqlc.AnalysisRun, event string) error { + channels, err := queries.ListSubscribedChannels(ctx, sqlc.ListSubscribedChannelsParams{ + WorkspaceID: run.WorkspaceID, + EventType: event, + }) + if err != nil { + return err + } + for _, target := range channels { + notificationID := uuid.New() + if _, err = queries.CreateNotification(ctx, sqlc.CreateNotificationParams{ + ID: notificationID, + WorkspaceID: run.WorkspaceID, + AnalysisRunID: run.ID, + ChannelID: target.ID, + EventType: event, + }); err != nil { + return err + } + dispatch, dispatchErr := workflow.NewDispatch( + workflow.AggregateNotification, + notificationID, + workflow.WorkflowNotificationDelivery, + workflow.Payload{AggregateID: notificationID, RepositoryID: run.RepositoryID, ChannelID: target.ID}, + ) + if dispatchErr != nil { + return dispatchErr + } + if _, err = queries.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{ + ID: dispatch.ID, + AggregateType: dispatch.AggregateType, + AggregateID: dispatch.AggregateID, + WorkflowName: dispatch.WorkflowName, + Payload: dispatch.Payload, + }); err != nil { + return err + } + } + + return nil +} diff --git a/internal/analysis/workflow/failure.go b/internal/analysis/workflow/failure.go new file mode 100644 index 0000000..2b6269f --- /dev/null +++ b/internal/analysis/workflow/failure.go @@ -0,0 +1,51 @@ +package workflow + +import ( + "errors" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" +) + +const ( + stagePrepare = "prepare" + stageAuthorize = "authorize" + stageCheckout = "checkout" + stageAnalyze = "analyze" + stagePersist = "persist" + stageWorkflow = "workflow" +) + +type executionError struct { + failure analysis.Failure + cause error +} + +func (e *executionError) Error() string { return e.cause.Error() } +func (e *executionError) Unwrap() error { return e.cause } + +func fail(stage, code, message string, retryable bool, cause error) error { + err := &executionError{ + failure: analysis.Failure{Stage: stage, Code: code, Message: message, Retryable: retryable}, + cause: cause, + } + if !retryable { + return workflowbiz.Permanent(err) + } + + return err +} + +func failureFrom(cause error) analysis.Failure { + var execution *executionError + if errors.As(cause, &execution) { + return execution.failure + } + + return analysis.Failure{ + Stage: stageWorkflow, + Code: "analysis.execution_failed", + Message: "Analysis failed", + Retryable: cause != nil && !workflowbiz.IsPermanent(cause), + } +} diff --git a/internal/analysis/workflow/runner.go b/internal/analysis/workflow/runner.go new file mode 100644 index 0000000..a88d456 --- /dev/null +++ b/internal/analysis/workflow/runner.go @@ -0,0 +1,170 @@ +package workflow + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "runtime" + "time" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/pkg/analyzer" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type Authorizer interface { + Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) +} + +type Runner struct { + store analysis.Store + authorizer Authorizer + git gitrepo.Manager + analyzer analyzer.Analyzer + timeout time.Duration +} + +func NewRunner(store analysis.Store, authorizer Authorizer, git gitrepo.Manager, engine analyzer.Analyzer, timeout time.Duration) *Runner { + return &Runner{store: store, authorizer: authorizer, git: git, analyzer: engine, timeout: timeout} +} + +func (r *Runner) Execute(ctx context.Context, runID uuid.UUID) error { + work, err := r.store.LoadWork(ctx, runID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + + return fail(stagePrepare, "analysis.run_load_failed", "Unable to load analysis run", true, err) + } + if work.Run.Status == "queued" { + if _, err = r.store.Start(ctx, runID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + + return fail(stagePrepare, "analysis.run_start_failed", "Unable to start analysis run", true, err) + } + } else if work.Run.Status != "running" { + return nil + } + if _, err := r.authorizer.Membership(ctx, auth.Actor{UserID: work.Run.RequestedBy}, work.Run.WorkspaceID, "member"); err != nil { + return fail(stageAuthorize, "analysis.authorization_revoked", "Requesting user no longer has workspace access", false, err) + } + definition, timeout, err := frozenProfile(work.Run.ProfileSnapshot, r.timeout) + if err != nil { + return fail(stagePrepare, "analysis.profile_snapshot_invalid", "Analysis profile snapshot is invalid", false, err) + } + proceed, err := r.advance(ctx, runID, stageCheckout) + if err != nil || !proceed { + return err + } + analysisContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + directory, cleanup, err := r.git.Checkout(analysisContext, work.Run.RepositoryID, work.CommitSHA) + if err != nil { + if gitrepo.IsPermanent(err) { + return fail(stageCheckout, "analysis.snapshot_unavailable", "Commit snapshot is no longer available", false, err) + } + + return fail(stageCheckout, "analysis.checkout_failed", "Unable to prepare commit snapshot", true, err) + } + defer func() { _ = cleanup() }() + proceed, err = r.advance(ctx, runID, stageAnalyze) + if err != nil || !proceed { + return err + } + result, err := r.analyzer.Analyze(analysisContext, analyzer.Request{ + Directory: directory, + CommitSHA: work.CommitSHA, + Parameters: append(json.RawMessage(nil), work.Run.ProfileSnapshot...), + }) + if err != nil { + switch { + case errors.Is(err, analyzer.ErrOutputTooLarge): + return fail(stageAnalyze, "analysis.output_too_large", "Analyzer output exceeded the configured limit", false, err) + case errors.Is(err, analyzer.ErrInvalidOutput): + return fail(stageAnalyze, "analysis.output_invalid", "Analyzer returned invalid output", false, err) + case errors.Is(err, context.DeadlineExceeded): + return fail(stageAnalyze, "analysis.timeout", "Analysis timed out", true, err) + default: + return fail(stageAnalyze, "analysis.analyzer_failed", "Analyzer execution failed", true, err) + } + } + if err := validateResult(work.Run, definition, result); err != nil { + return fail(stageAnalyze, "analysis.output_invalid", "Analyzer returned invalid output", false, err) + } + proceed, err = r.advance(ctx, runID, stagePersist) + if err != nil || !proceed { + return err + } + execution := analysis.Execution{ + Environment: runtime.Version() + "/" + runtime.GOOS + "/" + runtime.GOARCH, + } + _, err = r.store.Finish(ctx, runID, result.Data, result.RawArtifact, execution) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return fail(stagePersist, "analysis.result_persist_failed", "Unable to save analysis result", true, err) + } + + return nil +} + +func frozenProfile(snapshot json.RawMessage, ceiling time.Duration) (analysis.ProfileDefinition, time.Duration, error) { + var definition analysis.ProfileDefinition + if err := json.Unmarshal(snapshot, &definition); err != nil || definition.TimeoutSeconds < 1 || definition.TimeoutSeconds > 86400 || definition.DimensionKey != "code_scale" || definition.Analyzer.Key != "scc" || !definition.Analyzer.Required { + return analysis.ProfileDefinition{}, 0, errors.New("invalid frozen profile") + } + timeout := time.Duration(definition.TimeoutSeconds) * time.Second + if timeout > ceiling { + return definition, ceiling, nil + } + + return definition, timeout, nil +} + +func profileTimeout(snapshot json.RawMessage, ceiling time.Duration) (time.Duration, error) { + _, timeout, err := frozenProfile(snapshot, ceiling) + + return timeout, err +} + +func validateResult(run analysis.Run, definition analysis.ProfileDefinition, result analyzer.Result) error { + if result.DimensionKey != run.DimensionKey || result.DimensionKey != definition.DimensionKey { + return fmt.Errorf("%w: dimension key does not match frozen run", analyzer.ErrInvalidOutput) + } + if result.AnalyzerKey != definition.Analyzer.Key { + return fmt.Errorf("%w: analyzer key does not match frozen profile", analyzer.ErrInvalidOutput) + } + if result.AnalyzerVersion == "" || result.AnalyzerVersion != run.AnalyzerVersion { + return fmt.Errorf("%w: analyzer version does not match frozen run", analyzer.ErrInvalidOutput) + } + if len(result.Data) == 0 || !json.Valid(result.Data) { + return fmt.Errorf("%w: result data is not valid JSON", analyzer.ErrInvalidOutput) + } + if len(result.RawArtifact) == 0 || !json.Valid(result.RawArtifact) { + return fmt.Errorf("%w: raw artifact is not valid JSON", analyzer.ErrInvalidOutput) + } + + return nil +} + +func (r *Runner) advance(ctx context.Context, runID uuid.UUID, stage string) (bool, error) { + ok, err := r.store.SetStage(ctx, runID, stage) + if err != nil { + return false, fail(stage, "analysis.stage_update_failed", "Unable to update analysis progress", true, err) + } + + return ok, nil +} + +func (r *Runner) MarkFailed(ctx context.Context, runID uuid.UUID, cause error) error { + return r.store.Fail(ctx, runID, failureFrom(cause)) +} diff --git a/internal/analysis/workflow/runner_test.go b/internal/analysis/workflow/runner_test.go new file mode 100644 index 0000000..fd32cd1 --- /dev/null +++ b/internal/analysis/workflow/runner_test.go @@ -0,0 +1,294 @@ +package workflow + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/pkg/analyzer" + "github.com/fuchencong/mooncode/pkg/analyzer/scc" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" +) + +type analysisWorkflowStore struct { + analysis.Store + work analysis.WorkItem + started bool + result json.RawMessage + raw json.RawMessage + execution analysis.Execution + failed analysis.Failure + stages []string + stopAt string +} + +func (s *analysisWorkflowStore) LoadWork(context.Context, uuid.UUID) (analysis.WorkItem, error) { + if len(s.work.Run.ProfileSnapshot) == 0 { + s.work.Run.ProfileSnapshot = analysis.DefaultProfileDefinition() + } + if s.work.Run.DimensionKey == "" { + s.work.Run.DimensionKey = "code_scale" + } + if s.work.Run.AnalyzerVersion == "" { + s.work.Run.AnalyzerVersion = scc.Version + } + return s.work, nil +} + +func (s *analysisWorkflowStore) Start(context.Context, uuid.UUID) (analysis.Run, error) { + s.started = true + s.work.Run.Status = "running" + + return s.work.Run, nil +} + +func (s *analysisWorkflowStore) SetStage(_ context.Context, _ uuid.UUID, stage string) (bool, error) { + s.stages = append(s.stages, stage) + if stage == s.stopAt { + return false, nil + } + s.work.Run.Stage = stage + + return true, nil +} + +func (s *analysisWorkflowStore) Finish(_ context.Context, _ uuid.UUID, result, raw json.RawMessage, execution analysis.Execution) (analysis.Run, error) { + s.result = append(json.RawMessage(nil), result...) + s.raw = append(json.RawMessage(nil), raw...) + s.execution = execution + + return s.work.Run, nil +} + +func (s *analysisWorkflowStore) Fail(_ context.Context, _ uuid.UUID, failure analysis.Failure) error { + s.failed = failure + + return nil +} + +type analysisWorkflowAuthorizer struct { + actor auth.Actor + err error +} + +func (a *analysisWorkflowAuthorizer) Membership(_ context.Context, actor auth.Actor, _ uuid.UUID, _ string) (identity.Membership, error) { + a.actor = actor + + return identity.Membership{Role: "member"}, a.err +} + +type analysisWorkflowGit struct { + repositoryID uuid.UUID + commitSHA string + directory string + cleanup bool + checkoutErr error +} + +func (*analysisWorkflowGit) Provision(context.Context, uuid.UUID, string, string, uuid.UUID, gitrepo.Credential) (gitrepo.Snapshot, error) { + return gitrepo.Snapshot{}, nil +} + +func (*analysisWorkflowGit) Sync(context.Context, uuid.UUID, string, string, uuid.UUID, gitrepo.Credential) (gitrepo.Snapshot, error) { + return gitrepo.Snapshot{}, nil +} + +func (g *analysisWorkflowGit) Checkout(_ context.Context, repositoryID uuid.UUID, commitSHA string) (string, func() error, error) { + g.repositoryID = repositoryID + g.commitSHA = commitSHA + if g.checkoutErr != nil { + return "", nil, g.checkoutErr + } + + return g.directory, func() error { g.cleanup = true; return nil }, nil +} + +func (*analysisWorkflowGit) Purge(context.Context, uuid.UUID) error { return nil } +func (*analysisWorkflowGit) ReleaseSnapshot(context.Context, uuid.UUID, uuid.UUID, string) error { + return nil +} +func (*analysisWorkflowGit) Path(uuid.UUID) string { return "" } + +type analysisWorkflowAnalyzer struct { + request analyzer.Request + result analyzer.Result + err error + deadline bool +} + +func (a *analysisWorkflowAnalyzer) Analyze(ctx context.Context, request analyzer.Request) (analyzer.Result, error) { + a.request = request + _, a.deadline = ctx.Deadline() + + return a.result, a.err +} + +func TestRunnerAnalyzesPinnedCommitAndCleansCheckout(t *testing.T) { + runID, actorID, workspaceID, repositoryID := uuid.New(), uuid.New(), uuid.New(), uuid.New() + store := &analysisWorkflowStore{work: analysis.WorkItem{ + Run: analysis.Run{ID: runID, RequestedBy: actorID, WorkspaceID: workspaceID, RepositoryID: repositoryID, Status: "queued"}, + CommitSHA: "1234567890abcdef", + }} + authorizer := &analysisWorkflowAuthorizer{} + git := &analysisWorkflowGit{directory: "/tmp/checkout"} + analyzer := &analysisWorkflowAnalyzer{result: analyzer.Result{ + DimensionKey: "code_scale", + AnalyzerKey: "scc", + AnalyzerVersion: scc.Version, + Data: json.RawMessage(`{"summary":{"files":2,"code":42},"languages":[],"warnings":[]}`), + RawArtifact: json.RawMessage(`[{"Name":"Go"}]`), + }} + runner := NewRunner(store, authorizer, git, analyzer, time.Minute) + + if err := runner.Execute(context.Background(), runID); err != nil { + t.Fatal(err) + } + if !store.started || authorizer.actor.UserID != actorID || git.repositoryID != repositoryID || git.commitSHA != store.work.CommitSHA { + t.Fatalf("unexpected workflow identity: store=%+v authorizer=%+v git=%+v", store, authorizer, git) + } + if analyzer.request.Directory != git.directory || analyzer.request.CommitSHA != store.work.CommitSHA || string(analyzer.request.Parameters) != string(store.work.Run.ProfileSnapshot) || !analyzer.deadline || !git.cleanup { + t.Fatalf("analysis checkout lifecycle was incomplete: analyzer=%+v git=%+v", analyzer, git) + } + var result scc.Result + if err := json.Unmarshal(store.result, &result); err != nil { + t.Fatal(err) + } + if result.Summary.Files != 2 || result.Summary.Code != 42 { + t.Fatalf("unexpected persisted result: %+v", result) + } + if string(store.raw) != `[{"Name":"Go"}]` { + t.Fatalf("raw analyzer artifact was not persisted: %s", store.raw) + } + if store.execution.Environment == "" { + t.Fatal("analysis execution environment was not persisted") + } + if got := strings.Join(store.stages, ","); got != "checkout,analyze,persist" { + t.Fatalf("analysis stages = %q", got) + } +} + +func TestRunnerCleansCheckoutWhenAnalyzerFails(t *testing.T) { + store := &analysisWorkflowStore{work: analysis.WorkItem{ + Run: analysis.Run{ID: uuid.New(), RequestedBy: uuid.New(), WorkspaceID: uuid.New(), RepositoryID: uuid.New(), Status: "running"}, + CommitSHA: "abcdef", + }} + git := &analysisWorkflowGit{directory: "/tmp/checkout"} + analyzer := &analysisWorkflowAnalyzer{err: errors.New("scc failed")} + runner := NewRunner(store, &analysisWorkflowAuthorizer{}, git, analyzer, time.Minute) + + err := runner.Execute(context.Background(), store.work.Run.ID) + if err == nil { + t.Fatal("expected analyzer failure") + } + if !git.cleanup || store.result != nil { + t.Fatalf("failed analysis did not clean checkout or persisted a result: git=%+v result=%s", git, store.result) + } + if err := runner.MarkFailed(context.Background(), store.work.Run.ID, err); err != nil { + t.Fatal(err) + } + if store.failed.Stage != stageAnalyze || store.failed.Code != "analysis.analyzer_failed" || !store.failed.Retryable || store.failed.Message != "Analyzer execution failed" { + t.Fatalf("unexpected projected failure: %+v", store.failed) + } +} + +func TestRunnerMarksInvalidAnalyzerOutputAsNonRetryable(t *testing.T) { + store := &analysisWorkflowStore{work: analysis.WorkItem{ + Run: analysis.Run{ID: uuid.New(), RequestedBy: uuid.New(), WorkspaceID: uuid.New(), RepositoryID: uuid.New(), Status: "running"}, + CommitSHA: "abcdef", + }} + runner := NewRunner( + store, + &analysisWorkflowAuthorizer{}, + &analysisWorkflowGit{directory: "/tmp/checkout"}, + &analysisWorkflowAnalyzer{err: analyzer.ErrInvalidOutput}, + time.Minute, + ) + + err := runner.Execute(context.Background(), store.work.Run.ID) + if err == nil { + t.Fatal("expected invalid analyzer output failure") + } + if err := runner.MarkFailed(context.Background(), store.work.Run.ID, err); err != nil { + t.Fatal(err) + } + if store.failed.Stage != stageAnalyze || store.failed.Code != "analysis.output_invalid" || store.failed.Retryable { + t.Fatalf("unexpected projected failure: %+v", store.failed) + } +} + +func TestRunnerSkipsTerminalRun(t *testing.T) { + store := &analysisWorkflowStore{work: analysis.WorkItem{Run: analysis.Run{ID: uuid.New(), Status: "succeeded"}}} + git := &analysisWorkflowGit{} + analyzer := &analysisWorkflowAnalyzer{} + runner := NewRunner(store, &analysisWorkflowAuthorizer{}, git, analyzer, time.Minute) + + if err := runner.Execute(context.Background(), store.work.Run.ID); err != nil { + t.Fatal(err) + } + if store.started || analyzer.request.Directory != "" || git.repositoryID != uuid.Nil { + t.Fatal("terminal analysis was executed again") + } +} + +func TestRunnerRejectsResultMetadataThatDoesNotMatchFrozenRun(t *testing.T) { + store := &analysisWorkflowStore{work: analysis.WorkItem{ + Run: analysis.Run{ID: uuid.New(), RequestedBy: uuid.New(), WorkspaceID: uuid.New(), RepositoryID: uuid.New(), Status: "running"}, + CommitSHA: "abcdef", + }} + engine := &analysisWorkflowAnalyzer{result: analyzer.Result{ + DimensionKey: "code_scale", + AnalyzerKey: "scc", + AnalyzerVersion: "unexpected-version", + Data: json.RawMessage(`{}`), + RawArtifact: json.RawMessage(`[]`), + }} + runner := NewRunner(store, &analysisWorkflowAuthorizer{}, &analysisWorkflowGit{directory: "/tmp/checkout"}, engine, time.Minute) + + err := runner.Execute(context.Background(), store.work.Run.ID) + if err == nil { + t.Fatal("expected mismatched analyzer result to be rejected") + } + if err := runner.MarkFailed(context.Background(), store.work.Run.ID, err); err != nil { + t.Fatal(err) + } + if store.failed.Code != "analysis.output_invalid" || store.failed.Retryable { + t.Fatalf("unexpected projected failure: %+v", store.failed) + } +} + +func TestRunnerStopsWhenCancellationWinsStageTransition(t *testing.T) { + store := &analysisWorkflowStore{ + work: analysis.WorkItem{Run: analysis.Run{ID: uuid.New(), RequestedBy: uuid.New(), WorkspaceID: uuid.New(), RepositoryID: uuid.New(), Status: "running"}}, + stopAt: stageCheckout, + } + git := &analysisWorkflowGit{} + runner := NewRunner(store, &analysisWorkflowAuthorizer{}, git, &analysisWorkflowAnalyzer{}, time.Minute) + + if err := runner.Execute(context.Background(), store.work.Run.ID); err != nil { + t.Fatal(err) + } + if git.repositoryID != uuid.Nil { + t.Fatal("checkout ran after cancellation won the stage transition") + } +} + +func TestProfileTimeoutUsesSnapshotWithConfiguredCeiling(t *testing.T) { + timeout, err := profileTimeout(json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":30,"analyzer":{"key":"scc","required":true}}`), time.Minute) + if err != nil || timeout != 30*time.Second { + t.Fatalf("profileTimeout() = (%v, %v), want (30s, nil)", timeout, err) + } + timeout, err = profileTimeout(json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":120,"analyzer":{"key":"scc","required":true}}`), time.Minute) + if err != nil || timeout != time.Minute { + t.Fatalf("profileTimeout() ceiling = (%v, %v), want (1m, nil)", timeout, err) + } + if _, err := profileTimeout(json.RawMessage(`{"timeoutSeconds":0}`), time.Minute); err == nil { + t.Fatal("expected invalid profile timeout") + } +} diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go deleted file mode 100644 index 2974d02..0000000 --- a/internal/bootstrap/app.go +++ /dev/null @@ -1,364 +0,0 @@ -package bootstrap - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "net" - "net/http" - "os" - "os/signal" - "runtime/debug" - "sync" - "syscall" - "time" - - channelmanager "github.com/mooncode-ai/mooncode/internal/channel" - "github.com/mooncode-ai/mooncode/internal/checkout" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/controller" - "github.com/mooncode-ai/mooncode/internal/health" - "github.com/mooncode-ai/mooncode/internal/jobs" - "github.com/mooncode-ai/mooncode/internal/observability" - "github.com/mooncode-ai/mooncode/internal/outbox" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/repository/postgres" - "github.com/mooncode-ai/mooncode/internal/secretstore" - "github.com/mooncode-ai/mooncode/internal/service" - channelcore "github.com/mooncode-ai/mooncode/pkg/channel" - "github.com/mooncode-ai/mooncode/pkg/channel/dingtalk" - "github.com/mooncode-ai/mooncode/pkg/channel/fake" - "github.com/mooncode-ai/mooncode/pkg/channel/feishu" - gitcore "github.com/mooncode-ai/mooncode/pkg/git" - "github.com/mooncode-ai/mooncode/pkg/scm" - "github.com/rs/zerolog" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" - "go.uber.org/dig" - "golang.org/x/sync/errgroup" -) - -type BuildInfo struct { - Version string - Commit string - Date string -} - -type App struct { - cfg config.Config - application *http.Server - admin *http.Server - health *health.State - logger zerolog.Logger - providers observability.Shutdowner - database databaseResource - jobs backgroundResource - outbox backgroundResource - channels backgroundResource - shutdown sync.Once - shutdownErr error -} - -type databaseResource interface { - Check(context.Context) error - Close() -} - -type backgroundResource interface { - Run(context.Context) error -} - -func New(cfg config.Config, build BuildInfo) (*App, error) { - container := dig.New() - providers := []any{ - func() config.Config { return cfg }, - func() observability.ServiceInfo { - return observability.ServiceInfo{ - Version: build.Version, - Commit: build.Commit, - Environment: cfg.Service.Environment, - InstanceID: serviceInstanceID(), - } - }, - observability.NewDependencies, - postgres.NewStore, - func(store *postgres.Store) repository.IdentityStore { return store }, - func(store *postgres.Store) repository.MetadataStore { return store }, - func(store *postgres.Store) repository.OutboxStore { return store }, - func(store *postgres.Store) repository.ChannelStore { return store }, - func(store *postgres.Store) repository.MutationStore { return store }, - func(store *postgres.Store) secretstore.RecordStore { return store }, - newSecretStore, - newCheckoutStore, - newSCMRegistry, - newChannelRegistry, - newGitClient, - service.NewIdentityService, - service.NewRegistrationService, - service.NewWorkspaceService, - service.NewAuditService, - service.NewSCMConnectionService, - service.NewRepositoryService, - service.NewChannelService, - service.NewMessageService, - service.NewOverviewService, - controller.NewRepositoryController, - controller.NewWorkspaceController, - controller.NewAuditController, - controller.NewChannelController, - controller.NewOverviewController, - jobs.NewGitSyncHandler, - newJobRunner, - outbox.NewLocalPublisher, - newOutboxDispatcher, - newChannelManager, - controller.NewIdentityController, - controller.NewRegistrationController, - health.NewState, - newApplicationServer, - newAdminServer, - newApp, - } - for _, provider := range providers { - if err := container.Provide(provider); err != nil { - return nil, fmt.Errorf("provide dependency: %w", err) - } - } - - var app *App - if err := container.Invoke(func(resolved *App) { app = resolved }); err != nil { - return nil, fmt.Errorf("build application: %w", err) - } - return app, nil -} - -func newChannelRegistry(cfg config.Config) (*channelcore.Registry, error) { - options := []channelcore.RegistryOption{ - channelcore.WithFactory(feishu.NewFactory()), - channelcore.WithFactory(dingtalk.NewFactory()), - } - if cfg.Channels.FakeEnabled { - options = append(options, channelcore.WithFactory(fake.NewFactory())) - } - return channelcore.NewRegistry(options...) -} - -func newChannelManager(store repository.ChannelStore, secrets secretstore.SecretStore, registry *channelcore.Registry, info observability.ServiceInfo, cfg config.Config, logger zerolog.Logger, metrics *observability.Metrics, provider trace.TracerProvider) *channelmanager.Manager { - return channelmanager.NewManager(store, secrets, registry, info.InstanceID, cfg, logger, channelmanager.WithObserver(metrics), channelmanager.WithTracerProvider(provider)) -} - -func newSCMRegistry(cfg config.Config) (*scm.Registry, error) { - policyOptions := make([]scm.PolicyOption, 0, len(cfg.Git.AllowedPrivateCIDRs)) - for _, cidr := range cfg.Git.AllowedPrivateCIDRs { - _, network, err := net.ParseCIDR(cidr) - if err != nil { - return nil, fmt.Errorf("parse allowed Git network %q: %w", cidr, err) - } - policyOptions = append(policyOptions, scm.WithAllowedNetwork(network)) - } - policy := scm.NewURLPolicy(policyOptions...) - return scm.NewRegistry( - scm.WithProvider(scm.NewHTTPSProvider("github", policy, "github.com")), - scm.WithProvider(scm.NewHTTPSProvider("gitlab", policy)), - scm.WithProvider(scm.NewHTTPSProvider("generic", policy)), - ) -} - -func newJobRunner(store *postgres.Store, handler *jobs.GitSyncHandler, info observability.ServiceInfo, cfg config.Config, logger zerolog.Logger, metrics *observability.Metrics, provider trace.TracerProvider, propagator propagation.TextMapPropagator) (*jobs.Runner, error) { - return jobs.NewRunner(store, info.InstanceID, cfg, logger, jobs.WithHandler(handler), jobs.WithObserver(metrics), jobs.WithTracing(provider, propagator)) -} - -func newOutboxDispatcher(store repository.OutboxStore, publisher outbox.Publisher, info observability.ServiceInfo, cfg config.Config, logger zerolog.Logger, metrics *observability.Metrics, provider trace.TracerProvider, propagator propagation.TextMapPropagator) (*outbox.Dispatcher, error) { - return outbox.New(store, publisher, info.InstanceID, cfg, logger, metrics, provider, propagator) -} - -func newCheckoutStore(cfg config.Config) (checkout.Store, error) { - return checkout.NewFilesystemStore(cfg.Git.RepositoryDirectory) -} - -func newGitClient(cfg config.Config, metrics *observability.Metrics, provider trace.TracerProvider) gitcore.Client { - client := gitcore.NewClient(gitcore.WithDefaultTimeout(cfg.Git.CloneTimeout)) - return gitcore.Wrap(client, observability.InstrumentGit(metrics, provider)) -} - -func newSecretStore(cfg config.Config, records secretstore.RecordStore) (secretstore.SecretStore, error) { - key, err := base64.StdEncoding.DecodeString(cfg.SecretStore.MasterKey) - if err != nil { - return nil, fmt.Errorf("decode secret store master key: %w", err) - } - return secretstore.NewEncryptedStore(records, - secretstore.WithMasterKey(cfg.SecretStore.KeyVersion, key), - secretstore.WithCurrentKeyVersion(cfg.SecretStore.KeyVersion), - ) -} - -func newApp( - cfg config.Config, - application applicationServer, - admin adminServer, - state *health.State, - logger zerolog.Logger, - providers observability.Shutdowner, - database *postgres.Store, - jobRunner *jobs.Runner, - outboxDispatcher *outbox.Dispatcher, - channelManager *channelmanager.Manager, -) *App { - return &App{ - cfg: cfg, - application: application.server, - admin: admin.server, - health: state, - logger: logger, - providers: providers, - database: database, - jobs: jobRunner, - outbox: outboxDispatcher, - channels: channelManager, - } -} - -func (app *App) Run(parent context.Context) error { - ctx, stopSignals := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) - defer stopSignals() - - applicationListener, err := net.Listen("tcp", app.application.Addr) - if err != nil { - listenErr := fmt.Errorf("listen on application address %s: %w", app.application.Addr, err) - return errors.Join(listenErr, app.Shutdown(context.Background())) - } - adminListener, err := net.Listen("tcp", app.admin.Addr) - if err != nil { - _ = applicationListener.Close() - listenErr := fmt.Errorf("listen on admin address %s: %w", app.admin.Addr, err) - return errors.Join(listenErr, app.Shutdown(context.Background())) - } - - group, groupCtx := errgroup.WithContext(ctx) - fatal := make(chan error, 2) - serve := func(name string, server *http.Server, listener net.Listener) func() error { - return func() error { - err := runCritical(name, func() error { return server.Serve(listener) }) - if errors.Is(err, http.ErrServerClosed) { - return nil - } - if err != nil { - select { - case fatal <- err: - default: - } - } - return err - } - } - group.Go(serve("application HTTP server", app.application, applicationListener)) - group.Go(serve("admin HTTP server", app.admin, adminListener)) - group.Go(func() error { return app.monitorDatabase(groupCtx) }) - if app.jobs != nil { - group.Go(func() error { - return runCritical("job runner", func() error { return app.jobs.Run(groupCtx) }) - }) - } - if app.outbox != nil { - group.Go(func() error { - return runCritical("outbox dispatcher", func() error { return app.outbox.Run(groupCtx) }) - }) - } - if app.channels != nil { - group.Go(func() error { - return runCritical("channel manager", func() error { return app.channels.Run(groupCtx) }) - }) - } - - app.health.SetComponent("database", true) - app.health.SetComponent("jobs", app.jobs != nil) - app.health.SetComponent("outbox", app.outbox != nil) - app.health.SetComponent("channels", app.channels != nil) - app.health.SetReady(true) - app.logger.Info(). - Str("application_address", applicationListener.Addr().String()). - Str("admin_address", adminListener.Addr().String()). - Msg("mooncode backend started") - - var runErr error - select { - case <-ctx.Done(): - case <-groupCtx.Done(): - case runErr = <-fatal: - stopSignals() - } - - shutdownErr := app.Shutdown(context.Background()) - waitErr := group.Wait() - if runErr == nil && waitErr != nil && !errors.Is(waitErr, http.ErrServerClosed) { - runErr = waitErr - } - return errors.Join(runErr, shutdownErr) -} - -func (app *App) Shutdown(parent context.Context) error { - app.shutdown.Do(func() { - app.health.StartShutdown() - app.logger.Info().Msg("shutting down mooncode backend") - applicationErr := runShutdownPhase(parent, app.cfg.Shutdown.Timeout, app.application.Shutdown) - adminErr := runShutdownPhase(parent, app.cfg.Shutdown.Timeout, app.admin.Shutdown) - providerErr := runShutdownPhase(parent, app.cfg.Shutdown.Timeout, app.providers.Shutdown) - app.database.Close() - app.shutdownErr = errors.Join( - ignoreServerClosed(applicationErr), - ignoreServerClosed(adminErr), - providerErr, - ) - }) - return app.shutdownErr -} - -func (app *App) monitorDatabase(ctx context.Context) error { - ticker := time.NewTicker(app.cfg.Database.HealthInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - checkCtx, cancel := context.WithTimeout(ctx, app.cfg.Database.ConnectTimeout) - err := app.database.Check(checkCtx) - cancel() - app.health.SetComponent("database", err == nil) - if err != nil { - app.logger.Warn().Err(err).Str("operation", "database.health").Msg("PostgreSQL health check failed") - } - } - } -} - -func runShutdownPhase(parent context.Context, timeout time.Duration, function func(context.Context) error) error { - ctx, cancel := context.WithTimeout(parent, timeout) - defer cancel() - return function(ctx) -} - -func runCritical(name string, function func() error) (err error) { - defer func() { - if recovered := recover(); recovered != nil { - err = fmt.Errorf("%s panic (%T)\n%s", name, recovered, debug.Stack()) - } - }() - return function() -} - -func ignoreServerClosed(err error) error { - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err -} - -func serviceInstanceID() string { - hostname, err := os.Hostname() - if err != nil || hostname == "" { - hostname = "unknown" - } - return fmt.Sprintf("%s-%d", hostname, os.Getpid()) -} diff --git a/internal/bootstrap/http.go b/internal/bootstrap/http.go deleted file mode 100644 index 2f8a6bc..0000000 --- a/internal/bootstrap/http.go +++ /dev/null @@ -1,136 +0,0 @@ -package bootstrap - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/gorilla/csrf" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/controller" - "github.com/mooncode-ai/mooncode/internal/health" - "github.com/mooncode-ai/mooncode/internal/middleware" - "github.com/mooncode-ai/mooncode/internal/observability" - "github.com/mooncode-ai/mooncode/internal/service" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/rs/zerolog" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" -) - -type applicationServer struct { - server *http.Server -} - -type adminServer struct { - server *http.Server -} - -func newApplicationServer( - cfg config.Config, - logger zerolog.Logger, - metrics *observability.Metrics, - tracerProvider trace.TracerProvider, - propagator propagation.TextMapPropagator, - identities *service.IdentityService, - identityController *controller.IdentityController, - registrationController *controller.RegistrationController, - workspaceController *controller.WorkspaceController, - auditController *controller.AuditController, - repositoryController *controller.RepositoryController, - channelController *controller.ChannelController, - overviewController *controller.OverviewController, -) (applicationServer, error) { - engine := gin.New() - engine.Use( - middleware.RequestID(), - observability.TraceMiddleware(tracerProvider, propagator), - observability.AccessLogMiddleware(logger), - observability.MetricsMiddleware(metrics), - observability.RecoveryMiddleware(logger), - ) - api := engine.Group("/api/v1") - limiter := middleware.NewRateLimiter(cfg.Security.RateLimit, metrics) - api.Use(middleware.JSONBodyGuard(64<<10), middleware.GatewayIdentity(cfg.Identity, identities, logger), limiter.Middleware()) - api.GET("/csrf", func(c *gin.Context) { - c.Header("Cache-Control", "no-store") - c.JSON(http.StatusOK, gin.H{"token": csrf.Token(c.Request)}) - }) - identityController.Register(api) - registrationController.Register(api) - if workspaceController != nil { - workspaceController.Register(api) - } - if auditController != nil { - auditController.Register(api) - } - if repositoryController != nil { - repositoryController.Register(api) - } - if channelController != nil { - channelController.Register(api) - } - if overviewController != nil { - overviewController.Register(api) - } - engine.NoRoute(func(c *gin.Context) { - c.JSON(http.StatusNotFound, gin.H{ - "error": gin.H{ - "code": "http.not_found", - "message": "Route not found", - "requestId": middleware.RequestIDFromContext(c.Request.Context()), - }, - }) - }) - - key, err := base64.StdEncoding.DecodeString(cfg.Security.CSRF.Key) - if err != nil { - return applicationServer{}, fmt.Errorf("decode CSRF key: %w", err) - } - csrfHandler := csrf.Protect(key, - csrf.Secure(cfg.Security.CSRF.Secure), csrf.Path("/"), csrf.SameSite(csrf.SameSiteLaxMode), - csrf.RequestHeader("X-CSRF-Token"), csrf.TrustedOrigins(cfg.Security.CSRF.TrustedOrigins), - csrf.ErrorHandler(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(writer).Encode(map[string]any{"error": map[string]any{"code": "csrf.invalid", "message": "CSRF validation failed", "requestId": middleware.RequestIDFromContext(request.Context())}}) - })), - )(engine) - handler := middleware.RequestIDHTTP(csrfHandler) - return applicationServer{server: &http.Server{ - Addr: cfg.Server.Address, - Handler: handler, - ReadHeaderTimeout: cfg.Server.ReadHeaderTimeout, - ReadTimeout: cfg.Server.ReadTimeout, - WriteTimeout: cfg.Server.WriteTimeout, - IdleTimeout: cfg.Server.IdleTimeout, - }}, nil -} - -func newAdminServer( - cfg config.Config, - state *health.State, - registry *prometheus.Registry, - logger zerolog.Logger, -) adminServer { - mux := http.NewServeMux() - controller.NewHealthController(state).Register(mux) - mux.Handle("GET /metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) - - var handler http.Handler = mux - handler = observability.RecoveryHTTP(logger, handler) - handler = observability.AccessLogHTTP(logger, handler) - handler = middleware.RequestIDHTTP(handler) - - return adminServer{server: &http.Server{ - Addr: cfg.Admin.Address, - Handler: handler, - ReadHeaderTimeout: cfg.Admin.ReadHeaderTimeout, - ReadTimeout: cfg.Admin.ReadTimeout, - WriteTimeout: cfg.Admin.WriteTimeout, - IdleTimeout: cfg.Admin.IdleTimeout, - }} -} diff --git a/internal/bootstrap/http_test.go b/internal/bootstrap/http_test.go deleted file mode 100644 index c8d664b..0000000 --- a/internal/bootstrap/http_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package bootstrap - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/controller" - "github.com/mooncode-ai/mooncode/internal/health" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/observability" - "github.com/mooncode-ai/mooncode/internal/repository/memory" - "github.com/mooncode-ai/mooncode/internal/service" - "github.com/prometheus/client_golang/prometheus" - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace/noop" -) - -func TestApplicationServerReturnsStructuredNotFound(t *testing.T) { - gin.SetMode(gin.TestMode) - cfg, err := config.NewLoader().Load("", nil) - require.NoError(t, err) - cfg.Registration.Mode = "public" - registry := prometheus.NewRegistry() - store := memory.New() - identities := service.NewIdentityService(store, cfg) - registrations := service.NewRegistrationService(store, identities, cfg) - server, err := newApplicationServer( - cfg, - zerolog.New(io.Discard), - observability.NewMetrics(registry), - noop.NewTracerProvider(), - propagation.TraceContext{}, - identities, - controller.NewIdentityController(identities), - controller.NewRegistrationController(registrations, identities), - nil, - nil, - nil, - nil, - nil, - ) - require.NoError(t, err) - - response := httptest.NewRecorder() - server.server.Handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/missing", nil)) - require.Equal(t, http.StatusNotFound, response.Code) - require.Contains(t, response.Body.String(), `"code":"http.not_found"`) - require.NotEmpty(t, response.Header().Get("X-Request-ID")) -} - -func TestApplicationServerCSRFAndJSONProtection(t *testing.T) { - server, cfg := newTestApplicationServer(t) - - csrfRequest := authenticatedRequest(cfg, http.MethodGet, "/api/v1/csrf", nil) - csrfResponse := httptest.NewRecorder() - server.server.Handler.ServeHTTP(csrfResponse, csrfRequest) - require.Equal(t, http.StatusOK, csrfResponse.Code) - var csrfBody struct { - Token string `json:"token"` - } - require.NoError(t, json.Unmarshal(csrfResponse.Body.Bytes(), &csrfBody)) - require.NotEmpty(t, csrfBody.Token) - require.NotEmpty(t, csrfResponse.Result().Cookies()) - - tests := []struct { - name string - origin string - contentType string - token string - cookie bool - status int - code string - }{ - {name: "missing token", contentType: "application/json", cookie: true, status: http.StatusForbidden, code: "csrf.invalid"}, - {name: "untrusted origin", origin: "https://evil.example", contentType: "application/json", token: csrfBody.Token, cookie: true, status: http.StatusForbidden, code: "csrf.invalid"}, - {name: "valid token and origin", origin: "http://mooncode.localhost:3100", contentType: "application/json; charset=utf-8", token: csrfBody.Token, cookie: true, status: http.StatusNotFound, code: "http.not_found"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - request := authenticatedRequest(cfg, http.MethodPost, "/api/v1/missing", bytes.NewBufferString(`{}`)) - request.Header.Set("Content-Type", test.contentType) - if test.origin != "" { - request.Header.Set("Origin", test.origin) - } - if test.token != "" { - request.Header.Set("X-CSRF-Token", test.token) - } - if test.cookie { - for _, cookie := range csrfResponse.Result().Cookies() { - request.AddCookie(cookie) - } - } - response := httptest.NewRecorder() - server.server.Handler.ServeHTTP(response, request) - require.Equal(t, test.status, response.Code, response.Body.String()) - require.Contains(t, response.Body.String(), `"code":"`+test.code+`"`) - require.NotEmpty(t, response.Header().Get("X-Request-ID")) - }) - } -} - -func newTestApplicationServer(t *testing.T) (applicationServer, config.Config) { - t.Helper() - gin.SetMode(gin.TestMode) - cfg, err := config.NewLoader().Load("", nil) - require.NoError(t, err) - cfg.Registration.Mode = "public" - registry := prometheus.NewRegistry() - store := memory.New() - identities := service.NewIdentityService(store, cfg) - registrations := service.NewRegistrationService(store, identities, cfg) - principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon", Email: "moon@example.com"}) - require.NoError(t, err) - _, err = registrations.Complete(t.Context(), principal, service.CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true}) - require.NoError(t, err) - server, err := newApplicationServer( - cfg, zerolog.New(io.Discard), observability.NewMetrics(registry), - noop.NewTracerProvider(), propagation.TraceContext{}, identities, - controller.NewIdentityController(identities), controller.NewRegistrationController(registrations, identities), nil, nil, nil, nil, nil, - ) - require.NoError(t, err) - return server, cfg -} - -func authenticatedRequest(cfg config.Config, method, path string, body io.Reader) *http.Request { - request := httptest.NewRequest(method, path, body) - request.Header.Set("X-MoonCode-Gateway-Token", cfg.Identity.GatewayToken) - request.Header.Set("Remote-User", "moon") - request.Header.Set("Remote-Sub", "42") - return request -} - -func TestAdminServerUsesPrivateRegistry(t *testing.T) { - cfg, err := config.NewLoader().Load("", nil) - require.NoError(t, err) - state := health.NewState() - state.SetReady(true) - server := newAdminServer(cfg, state, prometheus.NewRegistry(), zerolog.New(io.Discard)) - - for path, expected := range map[string]int{ - "/livez": http.StatusOK, - "/readyz": http.StatusOK, - "/metrics": http.StatusOK, - } { - t.Run(path, func(t *testing.T) { - response := httptest.NewRecorder() - server.server.Handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil)) - require.Equal(t, expected, response.Code) - }) - } -} - -func TestRunCriticalConvertsPanic(t *testing.T) { - err := runCritical("test component", func() error { - panic("boom") - }) - require.ErrorContains(t, err, "test component panic (string)") -} - -func TestAppRunStopsWithContext(t *testing.T) { - gin.SetMode(gin.TestMode) - cfg, err := config.NewLoader().Load("", map[string]any{ - "server.address": "127.0.0.1:0", - "admin.address": "127.0.0.1:0", - }) - require.NoError(t, err) - state := health.NewState() - app := &App{ - cfg: cfg, - application: &http.Server{Addr: cfg.Server.Address, Handler: http.NewServeMux()}, - admin: &http.Server{Addr: cfg.Admin.Address, Handler: http.NewServeMux()}, - health: state, - logger: zerolog.New(io.Discard), - providers: testShutdowner{}, - database: testDatabase{}, - } - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - require.NoError(t, app.Run(ctx)) -} - -func TestCoreBackgroundPanicFailsReadinessAndStopsProcess(t *testing.T) { - cfg, err := config.NewLoader().Load("", map[string]any{ - "server.address": "127.0.0.1:0", - "admin.address": "127.0.0.1:0", - }) - require.NoError(t, err) - state := health.NewState() - app := &App{ - cfg: cfg, - application: &http.Server{Addr: cfg.Server.Address, Handler: http.NewServeMux()}, - admin: &http.Server{Addr: cfg.Admin.Address, Handler: http.NewServeMux()}, - health: state, - logger: zerolog.New(io.Discard), - providers: testShutdowner{}, - database: testDatabase{}, - jobs: panicBackground{}, - } - err = app.Run(t.Context()) - require.ErrorContains(t, err, "job runner panic (string)") - require.False(t, state.Ready()) -} - -type testDatabase struct{} - -func (testDatabase) Check(context.Context) error { return nil } -func (testDatabase) Close() {} - -type testShutdowner struct{} - -func (testShutdowner) Shutdown(context.Context) error { return nil } - -type panicBackground struct{} - -func (panicBackground) Run(context.Context) error { panic("boom") } diff --git a/internal/channel/biz/config.go b/internal/channel/biz/config.go new file mode 100644 index 0000000..eabb556 --- /dev/null +++ b/internal/channel/biz/config.go @@ -0,0 +1,76 @@ +package biz + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/fuchencong/mooncode/internal/platform/fault" +) + +type configuration struct { + Values map[string]any `json:"values"` + SenderAllowList []string `json:"senderAllowList"` + GroupPolicy groupPolicy `json:"groupPolicy"` +} + +type groupPolicy struct { + RequireMention bool `json:"requireMention"` + Prefix string `json:"prefix"` +} + +func validateConfiguration(kind string, config json.RawMessage, credentials string) error { + if err := validateValues(kind, config); err != nil { + return err + } + + var secrets map[string]string + if err := json.Unmarshal([]byte(credentials), &secrets); err != nil { + return fault.New(fault.Invalid, "channel.credentials_invalid", "Channel credentials must be a JSON object") + } + key := "app_secret" + if kind == "dingtalk" { + key = "client_secret" + } + if strings.TrimSpace(secrets[key]) == "" { + return fault.New(fault.Invalid, "channel.credential_required", fmt.Sprintf("Channel credential %q is required", key)) + } + + return nil +} + +func validateValues(kind string, raw json.RawMessage) error { + var config configuration + if err := json.Unmarshal(raw, &config); err != nil || config.Values == nil { + return fault.New(fault.Invalid, "channel.values_invalid", "Channel values must be a JSON object") + } + required := []string{"app_id", "receive_id"} + if kind == "dingtalk" { + required = []string{"client_id", "robot_code", "open_conversation_id"} + } + for _, key := range required { + value, ok := config.Values[key].(string) + if !ok || strings.TrimSpace(value) == "" { + return fault.New(fault.Invalid, "channel.value_required", fmt.Sprintf("Channel value %q is required", key)) + } + } + if len(config.SenderAllowList) == 0 { + return fault.New(fault.Invalid, "channel.allowlist_required", "Channel sender allowlist must contain at least one sender ID or an explicit wildcard") + } + seen := make(map[string]struct{}, len(config.SenderAllowList)) + for _, sender := range config.SenderAllowList { + sender = strings.TrimSpace(sender) + if sender == "" { + return fault.New(fault.Invalid, "channel.allowlist_invalid", "Channel sender allowlist cannot contain an empty sender ID") + } + seen[sender] = struct{}{} + } + if _, wildcard := seen["*"]; wildcard && len(seen) != 1 { + return fault.New(fault.Invalid, "channel.allowlist_invalid", "Channel sender allowlist wildcard cannot be combined with sender IDs") + } + if strings.ContainsAny(config.GroupPolicy.Prefix, "\r\n") { + return fault.New(fault.Invalid, "channel.group_prefix_invalid", "Channel group prefix must be a single line") + } + + return nil +} diff --git a/internal/channel/biz/config_test.go b/internal/channel/biz/config_test.go new file mode 100644 index 0000000..966e854 --- /dev/null +++ b/internal/channel/biz/config_test.go @@ -0,0 +1,32 @@ +package biz + +import ( + "encoding/json" + "testing" +) + +func TestValidateConfiguration(t *testing.T) { + tests := []struct { + name string + kind string + config string + credentials string + wantError bool + }{ + {name: "Feishu", kind: "feishu", config: `{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`, credentials: `{"app_secret":"secret"}`}, + {name: "DingTalk", kind: "dingtalk", config: `{"values":{"client_id":"app","robot_code":"robot","open_conversation_id":"conversation"},"senderAllowList":["dingtalk:user"]}`, credentials: `{"client_secret":"secret"}`}, + {name: "missing target", kind: "feishu", config: `{"values":{"app_id":"app"},"senderAllowList":["*"]}`, credentials: `{"app_secret":"secret"}`, wantError: true}, + {name: "missing secret", kind: "dingtalk", config: `{"values":{"client_id":"app","robot_code":"robot","open_conversation_id":"conversation"},"senderAllowList":["*"]}`, credentials: `{}`, wantError: true}, + {name: "missing allowlist", kind: "feishu", config: `{"values":{"app_id":"app","receive_id":"chat"}}`, credentials: `{"app_secret":"secret"}`, wantError: true}, + {name: "mixed wildcard", kind: "feishu", config: `{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*","feishu:user"]}`, credentials: `{"app_secret":"secret"}`, wantError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateConfiguration(test.kind, json.RawMessage(test.config), test.credentials) + if (err != nil) != test.wantError { + t.Fatalf("validateConfiguration() error = %v, wantError %v", err, test.wantError) + } + }) + } +} diff --git a/internal/channel/biz/model.go b/internal/channel/biz/model.go new file mode 100644 index 0000000..1a65e2e --- /dev/null +++ b/internal/channel/biz/model.go @@ -0,0 +1,60 @@ +package biz + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type Channel struct { + ID uuid.UUID `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + RuntimeStatus string `json:"-"` + SecretConfigured bool `json:"secretConfigured"` + ConfigVersion int64 `json:"configVersion"` + Config json.RawMessage `json:"config"` + NotificationEvents []string `json:"notificationEvents"` + LastConnectedAt *time.Time `json:"-"` + LastErrorMessage string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} +type Status struct { + State string `json:"state"` + LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"` + LastErrorMessage string `json:"lastErrorMessage,omitempty"` +} +type Conversation struct { + ID uuid.UUID `json:"id"` + ChannelInstanceID uuid.UUID `json:"channelInstanceId"` + ChannelName string `json:"channelName"` + ChannelType string `json:"channelType"` + ExternalID string `json:"externalId"` + Type string `json:"type"` + Title string `json:"title"` + CreatedAt time.Time `json:"createdAt"` +} +type Message struct { + ID uuid.UUID `json:"id"` + ChannelInstanceID uuid.UUID `json:"channelInstanceId"` + ChannelName string `json:"channelName"` + ChannelType string `json:"channelType"` + ConversationID uuid.UUID `json:"conversationId"` + ConversationExternalID string `json:"conversationExternalId"` + SenderCanonicalID string `json:"senderCanonicalId"` + SenderDisplayName string `json:"senderDisplayName"` + ConversationType string `json:"conversationType"` + Content json.RawMessage `json:"content"` + OccurredAt time.Time `json:"occurredAt"` +} +type Overview struct { + RepositoryCount int64 `json:"repositoryCount"` + RepositoryWithoutCodeCount int64 `json:"repositoryWithoutCodeCount"` + RecentFailedSyncCount int64 `json:"recentFailedSyncCount"` + ActiveAnalysisCount int64 `json:"activeAnalysisCount"` + ActiveChannelCount int64 `json:"activeChannelCount"` + RecentMessages []Message `json:"recentMessages"` +} diff --git a/internal/channel/biz/service.go b/internal/channel/biz/service.go new file mode 100644 index 0000000..3c5b599 --- /dev/null +++ b/internal/channel/biz/service.go @@ -0,0 +1,179 @@ +package biz + +import ( + "context" + "encoding/json" + "strings" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/platform/secret" + "github.com/google/uuid" +) + +type Store interface { + List(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Channel], error) + Create(context.Context, uuid.UUID, uuid.UUID, string, string, []byte, []byte, int, json.RawMessage, []string) (Channel, error) + Get(context.Context, uuid.UUID, uuid.UUID) (Channel, error) + Update(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, string, json.RawMessage, []string, int64) (Channel, error) + RotateCredential(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, []byte, []byte, int, int64) (Channel, error) + Enable(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, bool) (Channel, error) + Delete(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (bool, error) + Conversations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Conversation], error) + Messages(context.Context, uuid.UUID, uuid.NullUUID, uuid.NullUUID, pagination.Request) (pagination.Page[Message], error) + Overview(context.Context, uuid.UUID) (Overview, error) +} +type Authorizer interface { + Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) +} +type Service struct { + store Store + authorizer Authorizer + cipher secret.Cipher +} + +func NewService(store Store, authorizer Authorizer, cipher secret.Cipher) *Service { + return &Service{store: store, authorizer: authorizer, cipher: cipher} +} +func (s *Service) List(ctx context.Context, a auth.Actor, w uuid.UUID, page pagination.Request) (pagination.Page[Channel], error) { + if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil { + return pagination.Page[Channel]{}, err + } + return s.store.List(ctx, w, page) +} +func (s *Service) Create(ctx context.Context, a auth.Actor, w uuid.UUID, kind, name, credentials string, config json.RawMessage, notificationEvents []string) (Channel, error) { + if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil { + return Channel{}, err + } + if kind != "feishu" && kind != "dingtalk" { + return Channel{}, fault.New(fault.Invalid, "channel.type_invalid", "Channel type must be feishu or dingtalk") + } + if strings.TrimSpace(name) == "" { + return Channel{}, fault.New(fault.Invalid, "channel.name_required", "Channel name is required") + } + if err := validateConfiguration(kind, config, credentials); err != nil { + return Channel{}, err + } + notificationEvents, err := normalizeNotificationEvents(notificationEvents) + if err != nil { + return Channel{}, err + } + ciphertext, nonce, version, err := s.cipher.Encrypt([]byte(strings.TrimSpace(credentials))) + if err != nil { + return Channel{}, err + } + return s.store.Create(ctx, w, a.UserID, kind, name, ciphertext, nonce, version, config, notificationEvents) +} +func (s *Service) Update(ctx context.Context, a auth.Actor, w, id uuid.UUID, name string, config json.RawMessage, notificationEvents []string, configVersion int64) (Channel, error) { + if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil { + return Channel{}, err + } + item, err := s.store.Get(ctx, w, id) + if err != nil { + return Channel{}, err + } + name = strings.TrimSpace(name) + if name == "" { + return Channel{}, fault.New(fault.Invalid, "channel.name_required", "Channel name is required") + } + if err := validateValues(item.Type, config); err != nil { + return Channel{}, err + } + notificationEvents, err = normalizeNotificationEvents(notificationEvents) + if err != nil { + return Channel{}, err + } + if configVersion <= 0 || configVersion != item.ConfigVersion { + return Channel{}, fault.New(fault.Conflict, "channel.configuration_stale", "Channel configuration has changed; reload and try again") + } + + return s.store.Update(ctx, w, a.UserID, id, name, config, notificationEvents, configVersion) +} +func (s *Service) RotateCredential(ctx context.Context, a auth.Actor, w, id uuid.UUID, credentials string, configVersion int64) (Channel, error) { + if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil { + return Channel{}, err + } + item, err := s.store.Get(ctx, w, id) + if err != nil { + return Channel{}, err + } + if configVersion <= 0 || configVersion != item.ConfigVersion { + return Channel{}, fault.New(fault.Conflict, "channel.configuration_stale", "Channel configuration has changed; reload and try again") + } + credentials = strings.TrimSpace(credentials) + if err := validateConfiguration(item.Type, item.Config, credentials); err != nil { + return Channel{}, err + } + ciphertext, nonce, version, err := s.cipher.Encrypt([]byte(credentials)) + if err != nil { + return Channel{}, err + } + + return s.store.RotateCredential(ctx, w, a.UserID, id, ciphertext, nonce, version, configVersion) +} +func (s *Service) Enable(ctx context.Context, a auth.Actor, w, id uuid.UUID, enabled bool) (Channel, error) { + if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil { + return Channel{}, err + } + if enabled { + item, err := s.store.Get(ctx, w, id) + if err != nil { + return Channel{}, err + } + if !item.SecretConfigured { + return Channel{}, fault.New(fault.Conflict, "channel.credential_missing", "Channel credential is not configured") + } + if err := validateValues(item.Type, item.Config); err != nil { + return Channel{}, err + } + } + + return s.store.Enable(ctx, w, a.UserID, id, enabled) +} +func (s *Service) Delete(ctx context.Context, a auth.Actor, w, id uuid.UUID) error { + if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil { + return err + } + ok, err := s.store.Delete(ctx, w, a.UserID, id) + if err != nil { + return err + } + if !ok { + return fault.New(fault.NotFound, "channel.not_found", "Channel not found") + } + return nil +} +func (s *Service) Status(ctx context.Context, a auth.Actor, w, id uuid.UUID) (Status, error) { + if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil { + return Status{}, err + } + item, err := s.store.Get(ctx, w, id) + if err != nil { + return Status{}, err + } + state := item.RuntimeStatus + if state == "" { + state = "disabled" + } + return Status{State: state, LastConnectedAt: item.LastConnectedAt, LastErrorMessage: item.LastErrorMessage}, nil +} +func (s *Service) Conversations(ctx context.Context, a auth.Actor, w uuid.UUID, page pagination.Request) (pagination.Page[Conversation], error) { + if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil { + return pagination.Page[Conversation]{}, err + } + return s.store.Conversations(ctx, w, page) +} +func (s *Service) Messages(ctx context.Context, a auth.Actor, w uuid.UUID, channelID, conversationID uuid.NullUUID, page pagination.Request) (pagination.Page[Message], error) { + if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil { + return pagination.Page[Message]{}, err + } + return s.store.Messages(ctx, w, channelID, conversationID, page) +} +func (s *Service) Overview(ctx context.Context, a auth.Actor, w uuid.UUID) (Overview, error) { + if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil { + return Overview{}, err + } + return s.store.Overview(ctx, w) +} diff --git a/internal/channel/biz/service_test.go b/internal/channel/biz/service_test.go new file mode 100644 index 0000000..cebbe21 --- /dev/null +++ b/internal/channel/biz/service_test.go @@ -0,0 +1,108 @@ +package biz + +import ( + "context" + "encoding/json" + "testing" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/google/uuid" +) + +type channelAuthorizer struct{ requiredRole string } + +func (a *channelAuthorizer) Membership(_ context.Context, _ auth.Actor, _ uuid.UUID, requiredRole string) (identity.Membership, error) { + a.requiredRole = requiredRole + + return identity.Membership{Role: "admin"}, nil +} + +type channelStore struct { + Store + item Channel + rotated bool + ciphertext []byte + nonce []byte + keyVersion int + version int64 +} + +func (s *channelStore) Get(context.Context, uuid.UUID, uuid.UUID) (Channel, error) { + return s.item, nil +} + +func (s *channelStore) RotateCredential(_ context.Context, _, _, _ uuid.UUID, ciphertext, nonce []byte, keyVersion int, version int64) (Channel, error) { + s.rotated = true + s.ciphertext = append([]byte(nil), ciphertext...) + s.nonce = append([]byte(nil), nonce...) + s.keyVersion = keyVersion + s.version = version + s.item.ConfigVersion++ + + return s.item, nil +} + +type channelCipher struct{ plaintext []byte } + +func (c *channelCipher) Encrypt(plaintext []byte) ([]byte, []byte, int, error) { + c.plaintext = append([]byte(nil), plaintext...) + + return []byte("encrypted"), []byte("nonce"), 4, nil +} + +func (*channelCipher) Decrypt([]byte, []byte, int) ([]byte, error) { return nil, nil } + +func TestRotateCredentialUsesAdminAuthorizationAndOptimisticVersion(t *testing.T) { + workspaceID, channelID := uuid.New(), uuid.New() + store := &channelStore{item: Channel{ + ID: channelID, Type: "feishu", ConfigVersion: 3, + Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`), + }} + authorizer := &channelAuthorizer{} + cipher := &channelCipher{} + service := NewService(store, authorizer, cipher) + + updated, err := service.RotateCredential( + context.Background(), auth.Actor{UserID: uuid.New()}, workspaceID, channelID, + `{"app_secret":"replacement"}`, 3, + ) + if err != nil { + t.Fatal(err) + } + if authorizer.requiredRole != "admin" || !store.rotated || store.version != 3 || updated.ConfigVersion != 4 { + t.Fatalf("unexpected rotation: role=%q store=%+v updated=%+v", authorizer.requiredRole, store, updated) + } + if string(cipher.plaintext) != `{"app_secret":"replacement"}` || string(store.ciphertext) == string(cipher.plaintext) || store.keyVersion != 4 { + t.Fatalf("credential was not encrypted correctly: plaintext=%q ciphertext=%q version=%d", cipher.plaintext, store.ciphertext, store.keyVersion) + } +} + +func TestRotateCredentialRejectsStaleVersionBeforeEncryption(t *testing.T) { + store := &channelStore{item: Channel{ + ID: uuid.New(), Type: "dingtalk", ConfigVersion: 8, + Config: json.RawMessage(`{"values":{"client_id":"app","robot_code":"robot","open_conversation_id":"conversation"},"senderAllowList":["*"]}`), + }} + cipher := &channelCipher{} + service := NewService(store, &channelAuthorizer{}, cipher) + + if _, err := service.RotateCredential(context.Background(), auth.Actor{}, uuid.New(), store.item.ID, `{"client_secret":"replacement"}`, 7); err == nil { + t.Fatal("expected stale credential version to be rejected") + } + if store.rotated || cipher.plaintext != nil { + t.Fatal("stale credential request performed encryption or persistence") + } +} + +func TestNormalizeNotificationEventsValidatesAndCanonicalizes(t *testing.T) { + events, err := normalizeNotificationEvents([]string{EventAnalysisSucceeded, EventAnalysisStarted, EventAnalysisSucceeded}) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[0] != EventAnalysisStarted || events[1] != EventAnalysisSucceeded { + t.Fatalf("normalized events = %v", events) + } + if _, err := normalizeNotificationEvents([]string{"analysis.unknown"}); err == nil { + t.Fatal("expected unknown notification event to be rejected") + } +} diff --git a/internal/channel/biz/subscription.go b/internal/channel/biz/subscription.go new file mode 100644 index 0000000..e07f145 --- /dev/null +++ b/internal/channel/biz/subscription.go @@ -0,0 +1,38 @@ +package biz + +import ( + "sort" + + "github.com/fuchencong/mooncode/internal/platform/fault" +) + +const ( + EventAnalysisStarted = "analysis.started" + EventAnalysisSucceeded = "analysis.succeeded" + EventAnalysisFailed = "analysis.failed" +) + +var notificationEvents = map[string]struct{}{ + EventAnalysisStarted: {}, + EventAnalysisSucceeded: {}, + EventAnalysisFailed: {}, +} + +func normalizeNotificationEvents(events []string) ([]string, error) { + normalized := make([]string, 0, len(events)) + seen := make(map[string]struct{}, len(events)) + for _, event := range events { + if _, ok := notificationEvents[event]; !ok { + return nil, fault.New(fault.Invalid, "channel.subscription_event_invalid", "Channel notification event is invalid") + } + if _, duplicate := seen[event]; duplicate { + continue + } + + seen[event] = struct{}{} + normalized = append(normalized, event) + } + sort.Strings(normalized) + + return normalized, nil +} diff --git a/internal/channel/command/model.go b/internal/channel/command/model.go new file mode 100644 index 0000000..99a52d2 --- /dev/null +++ b/internal/channel/command/model.go @@ -0,0 +1,37 @@ +package command + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" +) + +var ( + ErrNotLinked = errors.New("channel identity is not linked") + ErrNoBinding = errors.New("conversation is not bound") + ErrInvalidLink = errors.New("channel identity link is invalid or expired") + ErrChannelInactive = errors.New("channel is disabled or its configuration is stale") +) + +type Link struct { + ID uuid.UUID + WorkspaceID uuid.UUID + ChannelID uuid.UUID + ChannelVersion int64 + SenderCanonicalID string + TokenHash []byte + ExpiresAt time.Time +} + +type Store interface { + CheckActiveChannel(context.Context, uuid.UUID, uuid.UUID, int64) error + ExternalUserID(context.Context, uuid.UUID, string) (uuid.UUID, error) + CreateLink(context.Context, Link) error + LinkByHash(context.Context, []byte) (Link, error) + AcceptLink(context.Context, Link, uuid.UUID) error + ConversationBinding(context.Context, uuid.UUID, uuid.UUID, int64, string) (uuid.UUID, error) + BindConversation(context.Context, uuid.UUID, uuid.UUID, int64, string, uuid.UUID, uuid.UUID) error + UnbindConversation(context.Context, uuid.UUID, uuid.UUID, int64, string) error +} diff --git a/internal/channel/command/options.go b/internal/channel/command/options.go new file mode 100644 index 0000000..584d346 --- /dev/null +++ b/internal/channel/command/options.go @@ -0,0 +1,21 @@ +package command + +import "time" + +type Option func(*Service) + +func WithClock(now func() time.Time) Option { + return func(service *Service) { + if now != nil { + service.now = now + } + } +} + +func WithRandom(random func([]byte) (int, error)) Option { + return func(service *Service) { + if random != nil { + service.random = random + } + } +} diff --git a/internal/channel/command/service.go b/internal/channel/command/service.go new file mode 100644 index 0000000..5a2bb05 --- /dev/null +++ b/internal/channel/command/service.go @@ -0,0 +1,340 @@ +package command + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "time" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/uuid" +) + +const linkLifetime = 10 * time.Minute + +type Authorizer interface { + Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) +} + +type Repositories interface { + Get(context.Context, auth.Actor, uuid.UUID, uuid.UUID) (repository.Repository, error) + List(context.Context, auth.Actor, uuid.UUID, pagination.Request) (pagination.Page[repository.Repository], error) + Snapshots(context.Context, auth.Actor, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[repository.Snapshot], error) +} + +type Analyses interface { + Create(context.Context, auth.Actor, uuid.UUID, uuid.UUID, analysis.CreateInput) (analysis.Run, error) + Get(context.Context, auth.Actor, uuid.UUID, uuid.UUID) (analysis.Run, error) +} + +type Service struct { + store Store + authorizer Authorizer + repositories Repositories + analyses Analyses + appURL string + now func() time.Time + random func([]byte) (int, error) +} + +func NewService(store Store, authorizer Authorizer, repositories Repositories, analyses Analyses, appURL string, options ...Option) *Service { + service := &Service{ + store: store, authorizer: authorizer, repositories: repositories, analyses: analyses, + appURL: appURL, now: time.Now, random: rand.Read, + } + for _, option := range options { + option(service) + } + + return service +} + +func (s *Service) Handle(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, message channelpkg.InboundMessage) (string, error) { + fields := strings.Fields(message.Text) + if len(fields) == 0 { + return "", nil + } + verb := strings.ToLower(fields[0]) + if verb == "help" { + return helpText(), nil + } + if err := s.store.CheckActiveChannel(ctx, workspaceID, channelID, channelVersion); err != nil { + return "", err + } + if verb == "link" { + return s.createLink(ctx, channelID, workspaceID, channelVersion, message.SenderCanonicalID) + } + + userID, err := s.store.ExternalUserID(ctx, channelID, message.SenderCanonicalID) + if errors.Is(err, ErrNotLinked) { + return "This account is not linked. Send `link` to create a secure web authorization link.", nil + } + if err != nil { + return "", err + } + actor := auth.Actor{UserID: userID} + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return "Your linked MoonCode user no longer has access to this workspace.", nil + } + + switch verb { + case "repos": + return s.listRepositories(ctx, actor, workspaceID) + case "bind": + return s.bind(ctx, actor, workspaceID, channelID, channelVersion, message.ConversationID, fields[1:]) + case "unbind": + if err := s.store.UnbindConversation(ctx, workspaceID, channelID, channelVersion, message.ConversationID); err != nil { + return "", err + } + return "This conversation is no longer bound to a repository.", nil + case "analyze": + return s.analyze(ctx, actor, workspaceID, channelID, channelVersion, message.ConversationID, fields[1:]) + case "status", "report": + return s.analysisStatus(ctx, actor, workspaceID, verb, fields[1:]) + default: + return "Unknown command.\n\n" + helpText(), nil + } +} + +func (s *Service) Accept(ctx context.Context, actor auth.Actor, token string) error { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(token)) + if err != nil || len(raw) != 32 { + return fault.New(fault.Invalid, "channel.identity_link_invalid", "Channel identity link is invalid or expired") + } + hash := sha256.Sum256(raw) + link, err := s.store.LinkByHash(ctx, hash[:]) + if errors.Is(err, ErrInvalidLink) { + return fault.New(fault.Invalid, "channel.identity_link_invalid", "Channel identity link is invalid or expired") + } + if err != nil { + return err + } + if _, err := s.authorizer.Membership(ctx, actor, link.WorkspaceID, "member"); err != nil { + return err + } + + if err := s.store.AcceptLink(ctx, link, actor.UserID); err != nil { + if errors.Is(err, ErrInvalidLink) { + return fault.New(fault.Invalid, "channel.identity_link_invalid", "Channel identity link is invalid or expired") + } + + return err + } + + return nil +} + +func (s *Service) createLink(ctx context.Context, channelID, workspaceID uuid.UUID, channelVersion int64, senderCanonicalID string) (string, error) { + raw := make([]byte, 32) + if _, err := s.random(raw); err != nil { + return "", fmt.Errorf("create channel identity link: %w", err) + } + hash := sha256.Sum256(raw) + link := Link{ID: uuid.New(), WorkspaceID: workspaceID, ChannelID: channelID, ChannelVersion: channelVersion, SenderCanonicalID: senderCanonicalID, TokenHash: hash[:], ExpiresAt: s.now().Add(linkLifetime)} + if err := s.store.CreateLink(ctx, link); err != nil { + return "", err + } + location, err := url.Parse(s.appURL) + if err != nil { + return "", fmt.Errorf("parse public app URL: %w", err) + } + query := location.Query() + query.Set("channelLink", base64.RawURLEncoding.EncodeToString(raw)) + location.RawQuery = query.Encode() + + return "Open this link within 10 minutes to connect this chat identity to your MoonCode user:\n" + location.String(), nil +} + +func (s *Service) listRepositories(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID) (string, error) { + page, err := s.repositories.List(ctx, actor, workspaceID, pagination.Request{Limit: 20}) + if err != nil { + return "", err + } + if len(page.Items) == 0 { + return "No repositories are available in this workspace.", nil + } + names := make([]string, 0, len(page.Items)+1) + for _, item := range page.Items { + names = append(names, fmt.Sprintf("- %s (%s)", item.Name, item.ID)) + } + sort.Strings(names) + if page.NextCursor != "" { + names = append(names, "- … more repositories are available in the web app") + } + + return "Available repositories:\n" + strings.Join(names, "\n"), nil +} + +func (s *Service) bind(ctx context.Context, actor auth.Actor, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string, arguments []string) (string, error) { + if len(arguments) == 0 { + return "Usage: bind ", nil + } + item, err := s.resolveRepository(ctx, actor, workspaceID, strings.Join(arguments, " ")) + if err != nil { + return err.Error(), nil + } + if err := s.store.BindConversation(ctx, workspaceID, channelID, channelVersion, conversationExternalID, item.ID, actor.UserID); err != nil { + return "", err + } + + return fmt.Sprintf("This conversation is now bound to %s.", item.Name), nil +} + +func (s *Service) analyze(ctx context.Context, actor auth.Actor, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string, arguments []string) (string, error) { + repositoryID, err := s.store.ConversationBinding(ctx, workspaceID, channelID, channelVersion, conversationExternalID) + if errors.Is(err, ErrNoBinding) { + return "Bind this conversation first with `bind `.", nil + } + if err != nil { + return "", err + } + selected, err := s.repositories.Get(ctx, actor, workspaceID, repositoryID) + if err != nil { + return "", err + } + + snapshotID := uuid.Nil + if len(arguments) > 0 { + var resolveErr error + snapshotID, resolveErr = s.resolveSnapshot(ctx, actor, workspaceID, repositoryID, arguments[0]) + if resolveErr != nil { + return resolveErr.Error(), nil + } + } + run, err := s.analyses.Create(ctx, actor, workspaceID, repositoryID, analysis.CreateInput{SnapshotID: snapshotID}) + if err != nil { + return "", err + } + + return fmt.Sprintf("Analysis queued for %s at %.12s.\nRun: %s", selected.Name, run.CommitSHA, run.ID), nil +} + +func (s *Service) analysisStatus(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, verb string, arguments []string) (string, error) { + if len(arguments) != 1 { + return fmt.Sprintf("Usage: %s ", verb), nil + } + runID, err := uuid.Parse(arguments[0]) + if err != nil { + return "Run ID is invalid.", nil + } + run, err := s.analyses.Get(ctx, actor, workspaceID, runID) + if err != nil { + return "", err + } + if verb == "status" || run.Report == nil { + message := fmt.Sprintf("Run %s is %s at %.12s.", run.ID, run.Status, run.CommitSHA) + if run.ErrorMessage != "" { + message += "\nError: " + run.ErrorMessage + } + return message, nil + } + var result struct { + Summary struct { + Files int64 `json:"files"` + Code int64 `json:"code"` + } `json:"summary"` + Languages []json.RawMessage `json:"languages"` + } + if err := json.Unmarshal(run.Report.Result, &result); err != nil { + return "", err + } + + return fmt.Sprintf("Report for %.12s: %d files, %d code lines, %d languages.\nRun: %s", run.CommitSHA, result.Summary.Files, result.Summary.Code, len(result.Languages), run.ID), nil +} + +func (s *Service) resolveRepository(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, reference string) (repository.Repository, error) { + reference = strings.TrimSpace(reference) + if parsedID, err := uuid.Parse(reference); err == nil { + item, getErr := s.repositories.Get(ctx, actor, workspaceID, parsedID) + if getErr != nil { + return repository.Repository{}, errors.New("repository was not found") + } + + return item, nil + } + + var matched repository.Repository + cursor := "" + for { + page, err := s.repositories.List(ctx, actor, workspaceID, pageWithCursor(cursor, 100)) + if err != nil { + return repository.Repository{}, err + } + for _, item := range page.Items { + if !strings.EqualFold(item.Name, reference) { + continue + } + if matched.ID != uuid.Nil { + return repository.Repository{}, errors.New("repository name is ambiguous; use its ID") + } + matched = item + } + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + } + if matched.ID == uuid.Nil { + return repository.Repository{}, errors.New("repository was not found") + } + + return matched, nil +} + +func (s *Service) resolveSnapshot(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, prefix string) (uuid.UUID, error) { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + matched := uuid.Nil + cursor := "" + for { + page, err := s.repositories.Snapshots(ctx, actor, workspaceID, repositoryID, pageWithCursor(cursor, 100)) + if err != nil { + return uuid.Nil, err + } + for _, snapshot := range page.Items { + if snapshot.SourceState != "available" || !strings.HasPrefix(strings.ToLower(snapshot.CommitSHA), prefix) { + continue + } + if matched != uuid.Nil { + return uuid.Nil, errors.New("commit prefix is ambiguous; provide more characters") + } + matched = snapshot.ID + } + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + } + if matched == uuid.Nil { + return uuid.Nil, errors.New("no available local snapshot matches that commit prefix") + } + + return matched, nil +} + +func pageWithCursor(cursor string, limit int32) pagination.Request { + request := pagination.Request{Limit: limit} + if cursor == "" { + return request + } + decoded, _ := pagination.Decode(cursor) + request.After = &decoded + + return request +} + +func helpText() string { + return "MoonCode commands:\n- link\n- repos\n- bind \n- analyze [commit-prefix]\n- status \n- report \n- unbind\n- help" +} diff --git a/internal/channel/command/service_test.go b/internal/channel/command/service_test.go new file mode 100644 index 0000000..048cd4d --- /dev/null +++ b/internal/channel/command/service_test.go @@ -0,0 +1,206 @@ +package command + +import ( + "context" + "crypto/sha256" + "errors" + "net/url" + "strings" + "testing" + "time" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/pagination" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/uuid" +) + +type commandStore struct { + link Link + externalUserID uuid.UUID + boundRepositoryID uuid.UUID + acceptedUserID uuid.UUID + activeError error + externalError error +} + +func (s *commandStore) CheckActiveChannel(context.Context, uuid.UUID, uuid.UUID, int64) error { + return s.activeError +} +func (s *commandStore) ExternalUserID(context.Context, uuid.UUID, string) (uuid.UUID, error) { + if s.externalError != nil { + return uuid.Nil, s.externalError + } + if s.externalUserID == uuid.Nil { + return uuid.Nil, ErrNotLinked + } + + return s.externalUserID, nil +} +func (s *commandStore) CreateLink(_ context.Context, link Link) error { + s.link = link + + return nil +} +func (s *commandStore) LinkByHash(_ context.Context, hash []byte) (Link, error) { + if string(hash) != string(s.link.TokenHash) { + return Link{}, ErrInvalidLink + } + + return s.link, nil +} +func (s *commandStore) AcceptLink(_ context.Context, _ Link, userID uuid.UUID) error { + s.acceptedUserID = userID + s.externalUserID = userID + + return nil +} +func (s *commandStore) ConversationBinding(context.Context, uuid.UUID, uuid.UUID, int64, string) (uuid.UUID, error) { + if s.boundRepositoryID == uuid.Nil { + return uuid.Nil, ErrNoBinding + } + + return s.boundRepositoryID, nil +} +func (s *commandStore) BindConversation(_ context.Context, _, _ uuid.UUID, _ int64, _ string, repositoryID, _ uuid.UUID) error { + s.boundRepositoryID = repositoryID + + return nil +} +func (s *commandStore) UnbindConversation(context.Context, uuid.UUID, uuid.UUID, int64, string) error { + s.boundRepositoryID = uuid.Nil + + return nil +} + +type commandAuthorizer struct{ err error } + +func (a commandAuthorizer) Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) { + return identity.Membership{Role: "member"}, a.err +} + +type commandRepositories struct { + items []repository.Repository + snapshots []repository.Snapshot +} + +func (r commandRepositories) Get(_ context.Context, _ auth.Actor, _ uuid.UUID, repositoryID uuid.UUID) (repository.Repository, error) { + for _, item := range r.items { + if item.ID == repositoryID { + return item, nil + } + } + + return repository.Repository{}, errors.New("not found") +} +func (r commandRepositories) List(context.Context, auth.Actor, uuid.UUID, pagination.Request) (pagination.Page[repository.Repository], error) { + return pagination.Page[repository.Repository]{Items: r.items}, nil +} +func (r commandRepositories) Snapshots(context.Context, auth.Actor, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[repository.Snapshot], error) { + return pagination.Page[repository.Snapshot]{Items: r.snapshots}, nil +} + +type commandAnalyses struct { + run analysis.Run + actor auth.Actor + input analysis.CreateInput + calls int +} + +func (a *commandAnalyses) Create(_ context.Context, actor auth.Actor, _ uuid.UUID, _ uuid.UUID, input analysis.CreateInput) (analysis.Run, error) { + a.actor, a.input = actor, input + a.calls++ + + return a.run, nil +} +func (a *commandAnalyses) Get(context.Context, auth.Actor, uuid.UUID, uuid.UUID) (analysis.Run, error) { + return a.run, nil +} + +func TestLinkRequiresWebAuthorizationAndBindsExactSender(t *testing.T) { + store := &commandStore{} + service := NewService( + store, commandAuthorizer{}, commandRepositories{}, &commandAnalyses{}, "https://mooncode.example/app", + WithClock(func() time.Time { return time.Unix(100, 0) }), + WithRandom(func(destination []byte) (int, error) { + for index := range destination { + destination[index] = byte(index) + } + return len(destination), nil + }), + ) + workspaceID, channelID := uuid.New(), uuid.New() + response, err := service.Handle(context.Background(), workspaceID, channelID, 1, channelpkg.InboundMessage{SenderCanonicalID: "feishu:sender", Text: "link"}) + if err != nil { + t.Fatal(err) + } + location, err := url.Parse(strings.TrimSpace(response[strings.LastIndex(response, "\n")+1:])) + if err != nil { + t.Fatal(err) + } + token := location.Query().Get("channelLink") + if token == "" || store.link.ChannelVersion != 1 || store.link.SenderCanonicalID != "feishu:sender" || store.link.ExpiresAt != time.Unix(100, 0).Add(linkLifetime) { + t.Fatalf("unexpected link: response=%q link=%+v", response, store.link) + } + raw := make([]byte, 32) + for index := range raw { + raw[index] = byte(index) + } + hash := sha256.Sum256(raw) + if string(store.link.TokenHash) != string(hash[:]) { + t.Fatal("link token was not stored as a hash") + } + + actor := auth.Actor{UserID: uuid.New()} + if err := service.Accept(context.Background(), actor, token); err != nil { + t.Fatal(err) + } + if store.acceptedUserID != actor.UserID { + t.Fatalf("linked user = %s, want %s", store.acceptedUserID, actor.UserID) + } +} + +func TestAnalyzeRequiresLinkedMemberAndConversationBinding(t *testing.T) { + workspaceID, channelID, userID, repositoryID, runID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New() + store := &commandStore{externalUserID: userID, boundRepositoryID: repositoryID} + analyses := &commandAnalyses{run: analysis.Run{ID: runID, CommitSHA: "1234567890abcdef"}} + service := NewService(store, commandAuthorizer{}, commandRepositories{items: []repository.Repository{{ID: repositoryID, Name: "mooncode", Status: "ready"}}}, analyses, "https://mooncode.example/app") + + response, err := service.Handle(context.Background(), workspaceID, channelID, 1, channelpkg.InboundMessage{ConversationID: "chat", SenderCanonicalID: "feishu:sender", Text: "analyze"}) + if err != nil { + t.Fatal(err) + } + if analyses.calls != 1 || analyses.actor.UserID != userID || analyses.input.ProfileID != uuid.Nil || !strings.Contains(response, runID.String()) { + t.Fatalf("analysis was not created with linked identity: response=%q analyses=%+v", response, analyses) + } + + blocked := &commandAnalyses{} + service = NewService(store, commandAuthorizer{err: errors.New("forbidden")}, commandRepositories{}, blocked, "https://mooncode.example/app") + response, err = service.Handle(context.Background(), workspaceID, channelID, 1, channelpkg.InboundMessage{ConversationID: "chat", SenderCanonicalID: "feishu:sender", Text: "analyze"}) + if err != nil || blocked.calls != 0 || !strings.Contains(response, "no longer has access") { + t.Fatalf("removed member command was not blocked: response=%q error=%v calls=%d", response, err, blocked.calls) + } +} + +func TestUnlinkedSenderCannotAnalyze(t *testing.T) { + analyses := &commandAnalyses{} + service := NewService(&commandStore{}, commandAuthorizer{}, commandRepositories{}, analyses, "https://mooncode.example/app") + + response, err := service.Handle(context.Background(), uuid.New(), uuid.New(), 1, channelpkg.InboundMessage{SenderCanonicalID: "dingtalk:sender", Text: "analyze"}) + if err != nil || analyses.calls != 0 || !strings.Contains(response, "not linked") { + t.Fatalf("unlinked command was not rejected: response=%q error=%v calls=%d", response, err, analyses.calls) + } +} + +func TestIdentityLookupFailureIsNotReportedAsUnlinked(t *testing.T) { + databaseError := errors.New("database unavailable") + service := NewService(&commandStore{externalError: databaseError}, commandAuthorizer{}, commandRepositories{}, &commandAnalyses{}, "https://mooncode.example/app") + + response, err := service.Handle(context.Background(), uuid.New(), uuid.New(), 1, channelpkg.InboundMessage{SenderCanonicalID: "dingtalk:sender", Text: "repos"}) + if response != "" || !errors.Is(err, databaseError) { + t.Fatalf("identity lookup = (%q, %v), want empty response and database error", response, err) + } +} diff --git a/internal/channel/data/audit_integration_test.go b/internal/channel/data/audit_integration_test.go new file mode 100644 index 0000000..e2e7d64 --- /dev/null +++ b/internal/channel/data/audit_integration_test.go @@ -0,0 +1,102 @@ +//go:build integration + +package data + +import ( + "context" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" +) + +func TestChannelMutationsPersistSanitizedAuditEvents(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + actorID, workspaceID := uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, actorID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Channel audit',$2,$3)`, workspaceID, "channel-audit-"+workspaceID.String(), actorID); err != nil { + t.Fatal(err) + } + + store := NewStore(pool) + config := json.RawMessage(`{"values":{"app_id":"sensitive-app-id","receive_id":"chat"},"senderAllowList":["*"]}`) + createdEvents := []string{"analysis.failed", "analysis.succeeded"} + created, err := store.Create(ctx, workspaceID, actorID, "feishu", "Audit", []byte("encrypted-channel-secret"), []byte("secret-nonce"), 1, config, createdEvents) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(created.NotificationEvents, createdEvents) { + t.Fatalf("created notification events = %v, want %v", created.NotificationEvents, createdEvents) + } + updatedEvents := []string{"analysis.started"} + updated, err := store.Update(ctx, workspaceID, actorID, created.ID, "Audit updated", config, updatedEvents, created.ConfigVersion) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(updated.NotificationEvents, updatedEvents) { + t.Fatalf("updated notification events = %v, want %v", updated.NotificationEvents, updatedEvents) + } + rotated, err := store.RotateCredential(ctx, workspaceID, actorID, created.ID, []byte("replacement-encrypted-secret"), []byte("replacement-nonce"), 2, updated.ConfigVersion) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(rotated.NotificationEvents, updatedEvents) { + t.Fatalf("rotated channel notification events = %v, want %v", rotated.NotificationEvents, updatedEvents) + } + if _, err := store.Enable(ctx, workspaceID, actorID, created.ID, true); err != nil { + t.Fatal(err) + } + if deleted, err := store.Delete(ctx, workspaceID, actorID, created.ID); err != nil || !deleted { + t.Fatalf("Delete() = (%v, %v), want (true, nil)", deleted, err) + } + + wantActions := map[string]int{ + audit.ActionChannelCreated: 1, + audit.ActionChannelConfigurationUpdated: 1, + audit.ActionChannelCredentialRotated: 1, + audit.ActionChannelEnabled: 1, + audit.ActionChannelDeleted: 1, + } + rows, err := pool.Query(ctx, `SELECT workspace_id,actor_user_id,action,metadata::text FROM audit_events WHERE resource_type=$1 AND resource_id=$2`, audit.ResourceChannel, created.ID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + seen := make(map[string]int) + for rows.Next() { + var eventWorkspaceID, eventActorID uuid.UUID + var action, metadata string + if err := rows.Scan(&eventWorkspaceID, &eventActorID, &action, &metadata); err != nil { + t.Fatal(err) + } + if eventWorkspaceID != workspaceID || eventActorID != actorID { + t.Fatalf("audit identity = (%s, %s), want (%s, %s)", eventWorkspaceID, eventActorID, workspaceID, actorID) + } + if strings.Contains(metadata, "sensitive-app-id") || strings.Contains(metadata, "encrypted-channel-secret") || strings.Contains(metadata, "secret-nonce") || strings.Contains(metadata, "replacement-encrypted-secret") || strings.Contains(metadata, "replacement-nonce") { + t.Fatalf("channel audit metadata leaked sensitive data: %s", metadata) + } + seen[action]++ + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + for action, count := range wantActions { + if seen[action] != count { + t.Fatalf("audit action %q count = %d, want %d; all=%v (final version %d)", action, seen[action], count, seen, rotated.ConfigVersion) + } + } +} diff --git a/internal/channel/data/command_store.go b/internal/channel/data/command_store.go new file mode 100644 index 0000000..8458895 --- /dev/null +++ b/internal/channel/data/command_store.go @@ -0,0 +1,129 @@ +package data + +import ( + "context" + "errors" + + channelcommand "github.com/fuchencong/mooncode/internal/channel/command" + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) CheckActiveChannel(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64) error { + _, err := s.queries.CheckActiveChannelVersion(ctx, sqlc.CheckActiveChannelVersionParams{ + ID: channelID, WorkspaceID: workspaceID, ConfigVersion: channelVersion, + }) + if errors.Is(err, pgx.ErrNoRows) { + return channelcommand.ErrChannelInactive + } + + return err +} + +func (s *Store) ExternalUserID(ctx context.Context, channelID uuid.UUID, senderCanonicalID string) (uuid.UUID, error) { + userID, err := s.queries.GetChannelExternalIdentity(ctx, sqlc.GetChannelExternalIdentityParams{ChannelID: channelID, SenderCanonicalID: senderCanonicalID}) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, channelcommand.ErrNotLinked + } + + return userID, err +} + +func (s *Store) CreateLink(ctx context.Context, link channelcommand.Link) error { + _, err := s.queries.CreateChannelIdentityLink(ctx, sqlc.CreateChannelIdentityLinkParams{ + ID: link.ID, WorkspaceID: link.WorkspaceID, ChannelID: link.ChannelID, ChannelVersion: link.ChannelVersion, + SenderCanonicalID: link.SenderCanonicalID, TokenHash: link.TokenHash, + ExpiresAt: pgtype.Timestamptz{Time: link.ExpiresAt, Valid: true}, + }) + if errors.Is(err, pgx.ErrNoRows) { + return channelcommand.ErrChannelInactive + } + + return err +} + +func (s *Store) LinkByHash(ctx context.Context, hash []byte) (channelcommand.Link, error) { + row, err := s.queries.GetChannelIdentityLink(ctx, hash) + if errors.Is(err, pgx.ErrNoRows) { + return channelcommand.Link{}, channelcommand.ErrInvalidLink + } + if err != nil { + return channelcommand.Link{}, err + } + + return channelcommand.Link{ID: row.ID, WorkspaceID: row.WorkspaceID, ChannelID: row.ChannelID, ChannelVersion: row.ChannelVersion, SenderCanonicalID: row.SenderCanonicalID, TokenHash: row.TokenHash, ExpiresAt: row.ExpiresAt.Time}, nil +} + +func (s *Store) AcceptLink(ctx context.Context, link channelcommand.Link, userID uuid.UUID) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + queries := s.queries.WithTx(tx) + count, err := queries.ConsumeChannelIdentityLink(ctx, link.ID) + if err != nil { + return err + } + if count != 1 { + return channelcommand.ErrInvalidLink + } + if err := queries.UpsertChannelExternalIdentity(ctx, sqlc.UpsertChannelExternalIdentityParams{ + ID: uuid.New(), WorkspaceID: link.WorkspaceID, ChannelID: link.ChannelID, + SenderCanonicalID: link.SenderCanonicalID, UserID: userID, + }); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (s *Store) ConversationBinding(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string) (uuid.UUID, error) { + conversationID, err := s.activeConversationID(ctx, workspaceID, channelID, channelVersion, conversationExternalID) + if err != nil { + return uuid.Nil, err + } + + repositoryID, err := s.queries.GetConversationBinding(ctx, conversationID) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, channelcommand.ErrNoBinding + } + + return repositoryID, err +} + +func (s *Store) BindConversation(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string, repositoryID, userID uuid.UUID) error { + conversationID, err := s.activeConversationID(ctx, workspaceID, channelID, channelVersion, conversationExternalID) + if err != nil { + return err + } + + return s.queries.UpsertConversationBinding(ctx, sqlc.UpsertConversationBindingParams{ConversationID: conversationID, WorkspaceID: workspaceID, RepositoryID: repositoryID, BoundBy: userID}) +} + +func (s *Store) UnbindConversation(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string) error { + conversationID, err := s.activeConversationID(ctx, workspaceID, channelID, channelVersion, conversationExternalID) + if err != nil { + return err + } + count, err := s.queries.DeleteConversationBinding(ctx, conversationID) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && count == 0) { + return nil + } + + return err +} + +func (s *Store) activeConversationID(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string) (uuid.UUID, error) { + conversationID, err := s.queries.GetConversationIDByExternal(ctx, sqlc.GetConversationIDByExternalParams{ + ChannelID: channelID, ExternalID: conversationExternalID, + WorkspaceID: workspaceID, ChannelVersion: channelVersion, + }) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, channelcommand.ErrChannelInactive + } + + return conversationID, err +} diff --git a/internal/channel/data/command_store_integration_test.go b/internal/channel/data/command_store_integration_test.go new file mode 100644 index 0000000..402bbe2 --- /dev/null +++ b/internal/channel/data/command_store_integration_test.go @@ -0,0 +1,146 @@ +//go:build integration + +package data + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "os" + "testing" + "time" + + channelcommand "github.com/fuchencong/mooncode/internal/channel/command" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" +) + +func TestCommandStorePersistsSecureScopedState(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + userID := uuid.New() + workspaceID := uuid.New() + channelID := uuid.New() + otherChannelID := uuid.New() + repositoryID := uuid.New() + conversationID := uuid.New() + conversationExternalID := "conversation-" + uuid.NewString() + senderCanonicalID := "feishu:ou_" + uuid.NewString() + + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Command store',$2,$3)`, workspaceID, "command-store-"+workspaceID.String(), userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) +VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, repositoryID, workspaceID, "github.com/example/"+repositoryID.String(), "/tmp/"+repositoryID.String()+".git", userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO channels (id,workspace_id,type,name,enabled,runtime_status,config_version) +VALUES ($1,$2,'feishu','primary',true,'connected',1),($3,$2,'feishu','other',true,'connected',1)`, channelID, workspaceID, otherChannelID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO conversations (id,workspace_id,channel_id,external_id,type,title) +VALUES ($1,$2,$3,$4,'group','Engineering')`, conversationID, workspaceID, channelID, conversationExternalID); err != nil { + t.Fatal(err) + } + + store := NewStore(pool) + rawToken := []byte("raw-channel-link-token-" + uuid.NewString()) + tokenHash := sha256.Sum256(rawToken) + link := channelcommand.Link{ + ID: uuid.New(), WorkspaceID: workspaceID, ChannelID: channelID, ChannelVersion: 1, + SenderCanonicalID: senderCanonicalID, TokenHash: tokenHash[:], ExpiresAt: time.Now().Add(time.Minute), + } + if err := store.CreateLink(ctx, link); err != nil { + t.Fatal(err) + } + + var storedHash []byte + if err := pool.QueryRow(ctx, `SELECT token_hash FROM channel_identity_links WHERE id=$1`, link.ID).Scan(&storedHash); err != nil { + t.Fatal(err) + } + if !bytes.Equal(storedHash, tokenHash[:]) || bytes.Equal(storedHash, rawToken) { + t.Fatal("channel identity link did not persist only the token hash") + } + + loaded, err := store.LinkByHash(ctx, tokenHash[:]) + if err != nil { + t.Fatal(err) + } + if loaded.ChannelID != channelID || loaded.ChannelVersion != 1 || loaded.SenderCanonicalID != senderCanonicalID { + t.Fatalf("link scope changed during persistence: %+v", loaded) + } + if err := store.AcceptLink(ctx, loaded, userID); err != nil { + t.Fatal(err) + } + if err := store.AcceptLink(ctx, loaded, userID); !errors.Is(err, channelcommand.ErrInvalidLink) { + t.Fatalf("second link consumption error = %v, want ErrInvalidLink", err) + } + + linkedUserID, err := store.ExternalUserID(ctx, channelID, senderCanonicalID) + if err != nil || linkedUserID != userID { + t.Fatalf("exact external identity = (%s, %v), want (%s, nil)", linkedUserID, err, userID) + } + if _, err := store.ExternalUserID(ctx, channelID, senderCanonicalID+"-other"); !errors.Is(err, channelcommand.ErrNotLinked) { + t.Fatalf("different sender error = %v, want ErrNotLinked", err) + } + if _, err := store.ExternalUserID(ctx, otherChannelID, senderCanonicalID); !errors.Is(err, channelcommand.ErrNotLinked) { + t.Fatalf("different channel error = %v, want ErrNotLinked", err) + } + + if err := store.BindConversation(ctx, workspaceID, channelID, 1, conversationExternalID, repositoryID, userID); err != nil { + t.Fatal(err) + } + persistedStore := NewStore(pool) + boundRepositoryID, err := persistedStore.ConversationBinding(ctx, workspaceID, channelID, 1, conversationExternalID) + if err != nil || boundRepositoryID != repositoryID { + t.Fatalf("persisted binding = (%s, %v), want (%s, nil)", boundRepositoryID, err, repositoryID) + } + + staleHash := sha256.Sum256([]byte("stale-" + uuid.NewString())) + staleLink := channelcommand.Link{ + ID: uuid.New(), WorkspaceID: workspaceID, ChannelID: channelID, ChannelVersion: 1, + SenderCanonicalID: senderCanonicalID, TokenHash: staleHash[:], ExpiresAt: time.Now().Add(time.Minute), + } + if err := store.CreateLink(ctx, staleLink); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE channels SET config_version=2 WHERE id=$1`, channelID); err != nil { + t.Fatal(err) + } + if err := store.CheckActiveChannel(ctx, workspaceID, channelID, 1); !errors.Is(err, channelcommand.ErrChannelInactive) { + t.Fatalf("stale channel check error = %v, want ErrChannelInactive", err) + } + if _, err := store.LinkByHash(ctx, staleHash[:]); !errors.Is(err, channelcommand.ErrInvalidLink) { + t.Fatalf("stale link error = %v, want ErrInvalidLink", err) + } + if err := store.BindConversation(ctx, workspaceID, channelID, 1, conversationExternalID, repositoryID, userID); !errors.Is(err, channelcommand.ErrChannelInactive) { + t.Fatalf("stale bind error = %v, want ErrChannelInactive", err) + } + + if _, err := pool.Exec(ctx, `UPDATE channels SET enabled=false,runtime_status='disabled' WHERE id=$1`, channelID); err != nil { + t.Fatal(err) + } + disabledLink := channelcommand.Link{ + ID: uuid.New(), WorkspaceID: workspaceID, ChannelID: channelID, ChannelVersion: 2, + SenderCanonicalID: senderCanonicalID, TokenHash: []byte(uuid.NewString()), ExpiresAt: time.Now().Add(time.Minute), + } + if err := store.CreateLink(ctx, disabledLink); !errors.Is(err, channelcommand.ErrChannelInactive) { + t.Fatalf("disabled link creation error = %v, want ErrChannelInactive", err) + } + if err := store.UnbindConversation(ctx, workspaceID, channelID, 2, conversationExternalID); !errors.Is(err, channelcommand.ErrChannelInactive) { + t.Fatalf("disabled unbind error = %v, want ErrChannelInactive", err) + } +} diff --git a/internal/channel/data/delivery_store.go b/internal/channel/data/delivery_store.go new file mode 100644 index 0000000..148b4e3 --- /dev/null +++ b/internal/channel/data/delivery_store.go @@ -0,0 +1,54 @@ +package data + +import ( + "context" + + channelworkflow "github.com/fuchencong/mooncode/internal/channel/workflow" + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) LoadDelivery(ctx context.Context, notificationID uuid.UUID) (channelworkflow.WorkItem, error) { + var item channelworkflow.WorkItem + var keyVersion pgtype.Int4 + err := s.pool.QueryRow(ctx, ` +SELECT n.id,n.status,n.event_type,n.workspace_id,n.analysis_run_id,n.channel_id, + c.type,c.name,c.enabled,c.secret_ciphertext,c.secret_nonce,c.key_version,c.config, + r.name,a.commit_sha,COALESCE(a.failed_stage,''),COALESCE(a.error_message,''),COALESCE(report.result,'{}'::jsonb) +FROM notifications n +JOIN channels c ON c.id=n.channel_id AND c.workspace_id=n.workspace_id +JOIN analysis_runs a ON a.id=n.analysis_run_id AND a.workspace_id=n.workspace_id +JOIN repositories r ON r.id=a.repository_id AND r.workspace_id=n.workspace_id +LEFT JOIN analysis_reports report ON report.id=a.report_id AND report.workspace_id=n.workspace_id +WHERE n.id=$1`, notificationID).Scan( + &item.NotificationID, &item.Status, &item.EventType, &item.WorkspaceID, &item.AnalysisRunID, &item.ChannelID, + &item.ChannelType, &item.ChannelName, &item.ChannelEnabled, &item.Ciphertext, &item.Nonce, &keyVersion, &item.Config, + &item.RepositoryName, &item.CommitSHA, &item.FailedStage, &item.ErrorMessage, &item.Result, + ) + item.KeyVersion = int(keyVersion.Int32) + + return item, err +} + +func (s *Store) StartDelivery(ctx context.Context, notificationID uuid.UUID) error { + _, err := s.queries.StartNotification(ctx, notificationID) + + return err +} + +func (s *Store) FinishDelivery(ctx context.Context, notificationID uuid.UUID) error { + _, err := s.queries.FinishNotification(ctx, notificationID) + + return err +} + +func (s *Store) CancelDelivery(ctx context.Context, notificationID uuid.UUID) error { + return s.queries.CancelNotification(ctx, notificationID) +} + +func (s *Store) FailDelivery(ctx context.Context, notificationID uuid.UUID, message string) error { + return s.queries.FailNotification(ctx, sqlc.FailNotificationParams{ + ID: notificationID, LastErrorMessage: pgtype.Text{String: message, Valid: message != ""}, + }) +} diff --git a/internal/channel/data/runtime_integration_test.go b/internal/channel/data/runtime_integration_test.go new file mode 100644 index 0000000..2c13d39 --- /dev/null +++ b/internal/channel/data/runtime_integration_test.go @@ -0,0 +1,87 @@ +//go:build integration + +package data + +import ( + "context" + "encoding/json" + "os" + "testing" + "time" + + channelruntime "github.com/fuchencong/mooncode/internal/channel/runtime" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/uuid" +) + +func TestRuntimePersistsInboundMessageExactlyOnce(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + userID, workspaceID, channelID := uuid.New(), uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Channel runtime',$2,$3)`, workspaceID, "channel-runtime-"+workspaceID.String(), userID); err != nil { + t.Fatal(err) + } + config := json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`) + if _, err := pool.Exec(ctx, ` +INSERT INTO channels (id,workspace_id,type,name,enabled,runtime_status,secret_ciphertext,secret_nonce,key_version,config) +VALUES ($1,$2,'feishu','runtime',true,'starting',$3,$4,1,$5)`, channelID, workspaceID, []byte("ciphertext"), []byte("nonce"), config); err != nil { + t.Fatal(err) + } + + store := NewStore(pool) + configurations, err := store.ListRuntimeChannels(ctx) + if err != nil { + t.Fatal(err) + } + var runtimeConfig channelruntime.Configuration + for _, item := range configurations { + if item.ID == channelID { + runtimeConfig = item + break + } + } + if runtimeConfig.ID != channelID || runtimeConfig.Version != 1 { + t.Fatalf("runtime configuration not found: %#v", configurations) + } + message := channelpkg.InboundMessage{ + ExternalID: "external-message", ConversationID: "external-conversation", ConversationType: "group", + ConversationTitle: "Engineering", SenderCanonicalID: "feishu:ou_user", SenderDisplayName: "Ada", + Text: "analyze", Mentioned: true, OccurredAt: time.Now().UTC(), + } + inserted, err := store.SaveInbound(ctx, runtimeConfig, message) + if err != nil || !inserted { + t.Fatalf("first SaveInbound() = (%v, %v), want (true, nil)", inserted, err) + } + inserted, err = store.SaveInbound(ctx, runtimeConfig, message) + if err != nil || inserted { + t.Fatalf("duplicate SaveInbound() = (%v, %v), want (false, nil)", inserted, err) + } + var conversations, messages int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM conversations WHERE channel_id=$1`, channelID).Scan(&conversations); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM messages WHERE channel_id=$1`, channelID).Scan(&messages); err != nil { + t.Fatal(err) + } + if conversations != 1 || messages != 1 { + t.Fatalf("expected one conversation and message, got %d and %d", conversations, messages) + } + + if _, err := pool.Exec(ctx, `UPDATE channels SET enabled=false, runtime_status='disabled' WHERE id=$1`, channelID); err != nil { + t.Fatal(err) + } + message.ExternalID = "stale-message" + inserted, err = store.SaveInbound(ctx, runtimeConfig, message) + if err != nil || inserted { + t.Fatalf("stale SaveInbound() = (%v, %v), want (false, nil)", inserted, err) + } +} diff --git a/internal/channel/data/runtime_store.go b/internal/channel/data/runtime_store.go new file mode 100644 index 0000000..f6b764c --- /dev/null +++ b/internal/channel/data/runtime_store.go @@ -0,0 +1,90 @@ +package data + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + channelruntime "github.com/fuchencong/mooncode/internal/channel/runtime" + "github.com/fuchencong/mooncode/internal/data/sqlc" + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) ListRuntimeChannels(ctx context.Context) ([]channelruntime.Configuration, error) { + rows, err := s.queries.ListRuntimeChannels(ctx) + if err != nil { + return nil, err + } + items := make([]channelruntime.Configuration, 0, len(rows)) + for _, row := range rows { + items = append(items, channelruntime.Configuration{ + ID: row.ID, WorkspaceID: row.WorkspaceID, Type: row.Type, Version: row.ConfigVersion, + Ciphertext: row.SecretCiphertext, Nonce: row.SecretNonce, KeyVersion: int(row.KeyVersion.Int32), Config: row.Config, + }) + } + + return items, nil +} + +func (s *Store) SaveInbound(ctx context.Context, configuration channelruntime.Configuration, message channelpkg.InboundMessage) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + queries := s.queries.WithTx(tx) + + version, err := queries.LockChannelRuntime(ctx, sqlc.LockChannelRuntimeParams{ID: configuration.ID, WorkspaceID: configuration.WorkspaceID}) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && version != configuration.Version) { + return false, nil + } + if err != nil { + return false, err + } + conversationID, err := queries.UpsertConversation(ctx, sqlc.UpsertConversationParams{ + ID: uuid.New(), WorkspaceID: configuration.WorkspaceID, ChannelID: configuration.ID, + ExternalID: message.ConversationID, Type: message.ConversationType, Title: message.ConversationTitle, + }) + if err != nil { + return false, err + } + content, err := json.Marshal(map[string]string{"type": "text", "text": message.Text}) + if err != nil { + return false, err + } + count, err := queries.InsertInboundMessage(ctx, sqlc.InsertInboundMessageParams{ + ID: uuid.New(), WorkspaceID: configuration.WorkspaceID, ChannelID: configuration.ID, + ConversationID: conversationID, ExternalID: message.ExternalID, + SenderCanonicalID: message.SenderCanonicalID, SenderDisplayName: message.SenderDisplayName, + Content: content, OccurredAt: pgtype.Timestamptz{Time: message.OccurredAt, Valid: true}, + }) + if err != nil { + return false, err + } + if err := tx.Commit(ctx); err != nil { + return false, err + } + + return count > 0, nil +} + +func (s *Store) MarkRuntimeConnected(ctx context.Context, id uuid.UUID, version int64) error { + count, err := s.queries.MarkChannelRuntimeConnected(ctx, sqlc.MarkChannelRuntimeConnectedParams{ID: id, ConfigVersion: version}) + if err == nil && count == 0 { + return fmt.Errorf("channel %s configuration is no longer active", id) + } + + return err +} + +func (s *Store) MarkRuntimeError(ctx context.Context, id uuid.UUID, version int64, message string) error { + _, err := s.queries.MarkChannelRuntimeError(ctx, sqlc.MarkChannelRuntimeErrorParams{ + ID: id, ConfigVersion: version, LastErrorMessage: pgtype.Text{String: message, Valid: message != ""}, + }) + + return err +} diff --git a/internal/channel/data/store.go b/internal/channel/data/store.go new file mode 100644 index 0000000..1253574 --- /dev/null +++ b/internal/channel/data/store.go @@ -0,0 +1,309 @@ +package data + +import ( + "context" + "encoding/json" + "time" + + channel "github.com/fuchencong/mooncode/internal/channel/biz" + "github.com/fuchencong/mooncode/internal/data/pagecursor" + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool + queries *sqlc.Queries +} + +func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool, queries: sqlc.New(pool)} } +func (s *Store) List(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[channel.Channel], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListChannels(ctx, sqlc.ListChannelsParams{ + WorkspaceID: workspaceID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[channel.Channel]{}, err + } + subscriptions, err := channelSubscriptions(ctx, s.queries, workspaceID) + if err != nil { + return pagination.Page[channel.Channel]{}, err + } + items := make([]channel.Channel, 0, len(rows)) + for _, row := range rows { + item := mapChannel(row) + item.NotificationEvents = subscriptions[row.ID] + items = append(items, item) + } + return pagination.Build(items, page.Limit, func(item channel.Channel) (time.Time, uuid.UUID) { + return item.CreatedAt, item.ID + }), nil +} +func (s *Store) Create(ctx context.Context, workspaceID, actorID uuid.UUID, kind, name string, ciphertext, nonce []byte, keyVersion int, config json.RawMessage, notificationEvents []string) (channel.Channel, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return channel.Channel{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + row, err := q.CreateChannel(ctx, sqlc.CreateChannelParams{ID: uuid.New(), WorkspaceID: workspaceID, Type: kind, Name: name, SecretCiphertext: ciphertext, SecretNonce: nonce, KeyVersion: pgtype.Int4{Int32: int32(keyVersion), Valid: len(ciphertext) > 0}, Config: config}) + if err != nil { + return channel.Channel{}, err + } + if err = replaceChannelSubscriptions(ctx, q, workspaceID, row.ID, notificationEvents); err != nil { + return channel.Channel{}, err + } + if err = recordChannel(ctx, q, workspaceID, actorID, row.ID, audit.ActionChannelCreated, map[string]any{"channelType": row.Type, "notificationEventCount": len(notificationEvents)}); err != nil { + return channel.Channel{}, err + } + if err = tx.Commit(ctx); err != nil { + return channel.Channel{}, err + } + + item := mapChannel(row) + item.NotificationEvents = notificationEvents + + return item, nil +} +func (s *Store) Get(ctx context.Context, workspaceID, id uuid.UUID) (channel.Channel, error) { + row, err := s.queries.GetChannel(ctx, sqlc.GetChannelParams{ID: id, WorkspaceID: workspaceID}) + if err != nil { + return channel.Channel{}, err + } + events, err := channelSubscriptionEvents(ctx, s.queries, workspaceID, id) + item := mapChannel(row) + item.NotificationEvents = events + + return item, err +} +func (s *Store) Update(ctx context.Context, workspaceID, actorID, id uuid.UUID, name string, config json.RawMessage, notificationEvents []string, configVersion int64) (channel.Channel, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return channel.Channel{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + row, err := q.UpdateChannel(ctx, sqlc.UpdateChannelParams{ID: id, WorkspaceID: workspaceID, Name: name, Config: config, ConfigVersion: configVersion}) + if err != nil { + return channel.Channel{}, err + } + if err = replaceChannelSubscriptions(ctx, q, workspaceID, id, notificationEvents); err != nil { + return channel.Channel{}, err + } + if err = recordChannel(ctx, q, workspaceID, actorID, id, audit.ActionChannelConfigurationUpdated, map[string]any{"configVersion": row.ConfigVersion, "notificationEventCount": len(notificationEvents)}); err != nil { + return channel.Channel{}, err + } + if err = tx.Commit(ctx); err != nil { + return channel.Channel{}, err + } + + item := mapChannel(row) + item.NotificationEvents = notificationEvents + + return item, nil +} +func (s *Store) RotateCredential(ctx context.Context, workspaceID, actorID, id uuid.UUID, ciphertext, nonce []byte, keyVersion int, configVersion int64) (channel.Channel, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return channel.Channel{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + row, err := q.RotateChannelCredential(ctx, sqlc.RotateChannelCredentialParams{ + ID: id, WorkspaceID: workspaceID, SecretCiphertext: ciphertext, SecretNonce: nonce, + KeyVersion: pgtype.Int4{Int32: int32(keyVersion), Valid: true}, ConfigVersion: configVersion, + }) + if err != nil { + return channel.Channel{}, err + } + events, err := channelSubscriptionEvents(ctx, q, workspaceID, id) + if err != nil { + return channel.Channel{}, err + } + if err = recordChannel(ctx, q, workspaceID, actorID, id, audit.ActionChannelCredentialRotated, map[string]any{"configVersion": row.ConfigVersion}); err != nil { + return channel.Channel{}, err + } + if err = tx.Commit(ctx); err != nil { + return channel.Channel{}, err + } + item := mapChannel(row) + item.NotificationEvents = events + + return item, nil +} +func (s *Store) Enable(ctx context.Context, workspaceID, actorID, id uuid.UUID, enabled bool) (channel.Channel, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return channel.Channel{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + row, err := q.SetChannelEnabled(ctx, sqlc.SetChannelEnabledParams{ID: id, WorkspaceID: workspaceID, Enabled: enabled}) + if err != nil { + return channel.Channel{}, err + } + events, err := channelSubscriptionEvents(ctx, q, workspaceID, id) + if err != nil { + return channel.Channel{}, err + } + action := audit.ActionChannelDisabled + if enabled { + action = audit.ActionChannelEnabled + } + if err = recordChannel(ctx, q, workspaceID, actorID, id, action, map[string]any{"configVersion": row.ConfigVersion}); err != nil { + return channel.Channel{}, err + } + if err = tx.Commit(ctx); err != nil { + return channel.Channel{}, err + } + item := mapChannel(row) + item.NotificationEvents = events + + return item, nil +} +func (s *Store) Delete(ctx context.Context, workspaceID, actorID, id uuid.UUID) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + if err = q.CancelChannelNotifications(ctx, sqlc.CancelChannelNotificationsParams{ + ChannelID: id, WorkspaceID: workspaceID, + }); err != nil { + return false, err + } + count, err := q.DeleteChannel(ctx, sqlc.DeleteChannelParams{ID: id, WorkspaceID: workspaceID}) + if err != nil || count == 0 { + return false, err + } + if err = recordChannel(ctx, q, workspaceID, actorID, id, audit.ActionChannelDeleted, nil); err != nil { + return false, err + } + if err = tx.Commit(ctx); err != nil { + return false, err + } + + return true, nil +} + +func recordChannel(ctx context.Context, writer audit.Writer, workspaceID, actorID, channelID uuid.UUID, action string, metadata map[string]any) error { + return audit.Record(ctx, writer, audit.Event{ + WorkspaceID: workspaceID, + ActorUserID: actorID, + Action: action, + Resource: audit.ResourceChannel, + ResourceID: channelID, + Metadata: metadata, + }) +} +func (s *Store) Conversations(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[channel.Conversation], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListConversations(ctx, sqlc.ListConversationsParams{ + WorkspaceID: workspaceID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[channel.Conversation]{}, err + } + items := make([]channel.Conversation, 0, len(rows)) + for _, row := range rows { + items = append(items, channel.Conversation{ID: row.ID, ChannelInstanceID: row.ChannelID, ChannelName: row.ChannelName, ChannelType: row.ChannelType, ExternalID: row.ExternalID, Type: row.Type, Title: row.Title, CreatedAt: row.CreatedAt.Time}) + } + + return pagination.Build(items, page.Limit, func(item channel.Conversation) (time.Time, uuid.UUID) { + return item.CreatedAt, item.ID + }), nil +} +func (s *Store) Messages(ctx context.Context, workspaceID uuid.UUID, channelID, conversationID uuid.NullUUID, page pagination.Request) (pagination.Page[channel.Message], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListMessages(ctx, sqlc.ListMessagesParams{ + WorkspaceID: workspaceID, + ChannelID: channelID, + ConversationID: conversationID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[channel.Message]{}, err + } + items := make([]channel.Message, 0, len(rows)) + for _, row := range rows { + items = append(items, channel.Message{ID: row.ID, ChannelInstanceID: row.ChannelID, ChannelName: row.ChannelName, ChannelType: row.ChannelType, ConversationID: row.ConversationID, ConversationExternalID: row.ConversationExternalID, SenderCanonicalID: row.SenderCanonicalID, SenderDisplayName: row.SenderDisplayName, ConversationType: row.ConversationType, Content: row.Content, OccurredAt: row.OccurredAt.Time}) + } + return pagination.Build(items, page.Limit, func(item channel.Message) (time.Time, uuid.UUID) { + return item.OccurredAt, item.ID + }), nil +} +func (s *Store) Overview(ctx context.Context, workspaceID uuid.UUID) (channel.Overview, error) { + row, err := s.queries.GetWorkspaceOverview(ctx, workspaceID) + if err != nil { + return channel.Overview{}, err + } + messages, _ := s.Messages(ctx, workspaceID, uuid.NullUUID{}, uuid.NullUUID{}, pagination.Request{Limit: 5}) + return channel.Overview{RepositoryCount: row.RepositoryCount, RepositoryWithoutCodeCount: row.RepositoryWithoutCodeCount, RecentFailedSyncCount: row.RecentFailedSyncCount, ActiveAnalysisCount: row.ActiveAnalysisCount, ActiveChannelCount: row.ActiveChannelCount, RecentMessages: messages.Items}, nil +} +func mapChannel(row sqlc.Channel) channel.Channel { + return channel.Channel{ID: row.ID, Type: row.Type, Name: row.Name, Enabled: row.Enabled, RuntimeStatus: row.RuntimeStatus, SecretConfigured: len(row.SecretCiphertext) > 0, ConfigVersion: row.ConfigVersion, Config: row.Config, LastConnectedAt: optionalTime(row.LastConnectedAt), LastErrorMessage: row.LastErrorMessage.String, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} +} + +func replaceChannelSubscriptions(ctx context.Context, queries *sqlc.Queries, workspaceID, channelID uuid.UUID, events []string) error { + if err := queries.DeleteChannelSubscriptions(ctx, sqlc.DeleteChannelSubscriptionsParams{ + WorkspaceID: workspaceID, + ChannelID: channelID, + }); err != nil { + return err + } + for _, event := range events { + if err := queries.CreateChannelSubscription(ctx, sqlc.CreateChannelSubscriptionParams{ + ID: uuid.New(), + WorkspaceID: workspaceID, + ChannelID: channelID, + EventType: event, + }); err != nil { + return err + } + } + + return nil +} + +func channelSubscriptionEvents(ctx context.Context, queries *sqlc.Queries, workspaceID, channelID uuid.UUID) ([]string, error) { + subscriptions, err := channelSubscriptions(ctx, queries, workspaceID) + if err != nil { + return nil, err + } + + return subscriptions[channelID], nil +} + +func channelSubscriptions(ctx context.Context, queries *sqlc.Queries, workspaceID uuid.UUID) (map[uuid.UUID][]string, error) { + rows, err := queries.ListChannelSubscriptions(ctx, workspaceID) + if err != nil { + return nil, err + } + subscriptions := make(map[uuid.UUID][]string) + for _, row := range rows { + subscriptions[row.ChannelID] = append(subscriptions[row.ChannelID], row.EventType) + } + + return subscriptions, nil +} + +func optionalTime(value pgtype.Timestamptz) *time.Time { + if !value.Valid { + return nil + } + result := value.Time + return &result +} diff --git a/internal/channel/manager.go b/internal/channel/manager.go deleted file mode 100644 index 41490c5..0000000 --- a/internal/channel/manager.go +++ /dev/null @@ -1,409 +0,0 @@ -// Package channel owns MoonCode-specific Channel runtime supervision. -package channel - -import ( - "context" - "errors" - "fmt" - "math/rand/v2" - "runtime/debug" - "sync" - "sync/atomic" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/sanitize" - "github.com/mooncode-ai/mooncode/internal/secretstore" - channelcore "github.com/mooncode-ai/mooncode/pkg/channel" - "github.com/rs/zerolog" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" - "go.opentelemetry.io/otel/trace/noop" -) - -type ManagerOption func(*Manager) - -type Observer interface { - AddChannelRuntime(channelType, state string, delta float64) - ObserveChannelReconnect(channelType, result string) - ObserveChannelMessage(channelType, result string) -} - -func WithClock(clock func() time.Time) ManagerOption { - return func(manager *Manager) { - if clock != nil { - manager.clock = clock - } - } -} - -func WithObserver(observer Observer) ManagerOption { - return func(manager *Manager) { manager.observer = observer } -} - -func WithTracerProvider(provider trace.TracerProvider) ManagerOption { - return func(manager *Manager) { - if provider != nil { - manager.tracer = provider.Tracer("github.com/mooncode-ai/mooncode/internal/channel") - } - } -} - -func WithRetryJitter(jitter func(time.Duration) time.Duration) ManagerOption { - return func(manager *Manager) { - if jitter != nil { - manager.jitter = jitter - } - } -} - -type Manager struct { - store repository.ChannelStore - secrets secretstore.SecretStore - registry *channelcore.Registry - owner string - cfg config.ChannelsConfig - logger zerolog.Logger - clock func() time.Time - ready atomic.Bool - observer Observer - jitter func(time.Duration) time.Duration - tracer trace.Tracer -} - -func NewManager(store repository.ChannelStore, secrets secretstore.SecretStore, registry *channelcore.Registry, owner string, cfg config.Config, logger zerolog.Logger, opts ...ManagerOption) *Manager { - provider := noop.NewTracerProvider() - manager := &Manager{store: store, secrets: secrets, registry: registry, owner: owner, cfg: cfg.Channels, logger: logger, clock: time.Now, tracer: provider.Tracer("github.com/mooncode-ai/mooncode/internal/channel"), jitter: func(delay time.Duration) time.Duration { - return time.Duration(float64(delay) * (0.8 + rand.Float64()*0.4)) - }} - for _, option := range opts { - if option != nil { - option(manager) - } - } - return manager -} -func (m *Manager) Ready() bool { return m.ready.Load() } - -type activeRuntime struct { - instance model.ChannelInstance - lease model.ChannelLease - cancel context.CancelFunc - sink *runtimeSink - done chan struct{} - started time.Time - attempt int -} -type runtimeResult struct { - id uuid.UUID - token int64 - err error -} -type retryState struct { - attempt int - next time.Time -} - -func (m *Manager) Run(ctx context.Context) error { - active := make(map[uuid.UUID]*activeRuntime) - retries := make(map[uuid.UUID]retryState) - results := make(chan runtimeResult, 64) - ticker := time.NewTicker(m.cfg.ReconcileInterval) - defer ticker.Stop() - m.ready.Store(true) - defer m.ready.Store(false) - if err := m.reconcile(ctx, active, retries, results); err != nil { - m.logger.Warn().Err(err).Str("operation", "channel.reconcile").Msg("initial channel reconcile failed") - } - for { - select { - case <-ctx.Done(): - m.stopAll(active) - return nil - case result := <-results: - m.handleResult(active, retries, result) - case <-ticker.C: - if err := m.reconcile(ctx, active, retries, results); err != nil { - m.logger.Warn().Err(err).Str("operation", "channel.reconcile").Msg("channel reconcile failed") - } - } - } -} - -func (m *Manager) reconcile(ctx context.Context, active map[uuid.UUID]*activeRuntime, retries map[uuid.UUID]retryState, results chan<- runtimeResult) error { - instances, err := m.store.ListEnabledChannels(ctx) - if err != nil { - return err - } - desired := make(map[uuid.UUID]model.ChannelInstance, len(instances)) - for _, instance := range instances { - desired[instance.ID] = instance - } - for id, running := range active { - instance, wanted := desired[id] - if !wanted || instance.ConfigVersion != running.instance.ConfigVersion { - m.stop(running) - delete(active, id) - delete(retries, id) - continue - } - if err := m.store.RenewChannelLease(ctx, running.lease, m.clock().Add(m.cfg.LeaseDuration)); err != nil { - m.stop(running) - delete(active, id) - } - } - for id := range retries { - if _, wanted := desired[id]; !wanted { - delete(retries, id) - } - } - for id, instance := range desired { - if active[id] != nil { - continue - } - retry := retries[id] - if m.clock().Before(retry.next) { - continue - } - running, err := m.start(ctx, instance, retry.attempt, results) - if err != nil { - if !errors.Is(err, repository.ErrNotFound) { - m.logger.Warn().Err(err).Str("operation", "channel.start").Str("channel_type", instance.Type).Msg("unable to start channel runtime") - retries[id] = m.nextRetry(retry.attempt + 1) - } - continue - } - active[id] = running - } - return nil -} - -func (m *Manager) start(parent context.Context, instance model.ChannelInstance, attempt int, results chan<- runtimeResult) (*activeRuntime, error) { - lease, err := m.store.AcquireChannelLease(parent, instance.ID, m.owner, m.clock().Add(m.cfg.LeaseDuration)) - if err != nil { - return nil, err - } - factory, ok := m.registry.Factory(instance.Type) - if !ok { - _ = m.setStatus(instance.ID, lease.FencingToken, "misconfigured", "channel.unknown_type", "Unknown channel type") - _ = m.store.ReleaseChannelLease(parent, lease) - return nil, errors.New("unknown channel type") - } - secrets := map[string]string{} - if instance.SecretRef != nil { - ref := secretstore.SecretRef{ID: *instance.SecretRef, Scope: secretstore.Scope{WorkspaceID: instance.WorkspaceID, ResourceType: "channel_instance", ResourceID: instance.ID}} - values, err := m.secrets.Get(parent, ref) - if err != nil { - _ = m.setStatus(instance.ID, lease.FencingToken, "misconfigured", "channel.secret_failed", "Unable to decrypt channel credential") - _ = m.store.ReleaseChannelLease(parent, lease) - return nil, err - } - for key, value := range values { - secrets[key] = value.Reveal() - } - } - config := channelcore.InstanceConfig{AccountID: instance.ID.String(), Values: instance.Config.Values, Secrets: secrets, SenderAllowList: instance.Config.SenderAllowList, GroupPolicy: instance.Config.GroupPolicy} - if err := factory.Validate(config); err != nil { - _ = m.setStatus(instance.ID, lease.FencingToken, "misconfigured", "channel.invalid_config", "Channel configuration is invalid") - _ = m.store.ReleaseChannelLease(parent, lease) - return nil, err - } - sink := &runtimeSink{store: m.store, instance: instance, observer: m.observer, tracer: m.tracer} - sink.accepting.Store(true) - runtime, err := factory.New(config, sink, channelcore.WithInboundMiddleware(recoverInbound)) - if err != nil { - sink.accepting.Store(false) - _ = m.setStatus(instance.ID, lease.FencingToken, "misconfigured", "channel.create_failed", "Unable to create channel runtime") - _ = m.store.ReleaseChannelLease(parent, lease) - return nil, err - } - ctx, cancel := context.WithCancel(parent) - running := &activeRuntime{instance: instance, lease: lease, cancel: cancel, sink: sink, done: make(chan struct{}), started: m.clock(), attempt: attempt} - now := m.clock().UTC() - _ = m.store.SetChannelStatus(parent, model.ChannelRuntimeStatus{ChannelInstanceID: instance.ID, State: "running", BackendInstanceID: m.owner, FencingToken: lease.FencingToken, LastConnectedAt: &now}) - if m.observer != nil { - m.observer.AddChannelRuntime(instance.Type, "running", 1) - m.observer.ObserveChannelReconnect(instance.Type, "connected") - } - go func() { - defer close(running.done) - err := runRuntime(ctx, runtime) - select { - case results <- runtimeResult{id: instance.ID, token: lease.FencingToken, err: err}: - return - case <-parent.Done(): - return - } - }() - return running, nil -} - -func (m *Manager) handleResult(active map[uuid.UUID]*activeRuntime, retries map[uuid.UUID]retryState, result runtimeResult) { - running := active[result.id] - if running == nil || running.lease.FencingToken != result.token { - return - } - running.sink.accepting.Store(false) - running.sink.wait(m.cfg.DrainTimeout) - delete(active, result.id) - attempt := running.attempt + 1 - if m.clock().Sub(running.started) >= 2*m.cfg.LeaseDuration { - attempt = 1 - } - retries[result.id] = m.nextRetry(attempt) - if m.observer != nil { - m.observer.AddChannelRuntime(running.instance.Type, "running", -1) - if result.err != nil { - m.observer.ObserveChannelReconnect(running.instance.Type, "failed") - } else { - m.observer.ObserveChannelReconnect(running.instance.Type, "disconnected") - } - } - if result.err != nil { - _ = m.setStatus(result.id, result.token, "retry_wait", "channel.runtime_failed", safeMessage(result.err)) - } else { - _ = m.setStatus(result.id, result.token, "retry_wait", "channel.disconnected", "Channel runtime disconnected") - } - _ = m.store.ReleaseChannelLease(context.Background(), running.lease) -} - -func (m *Manager) nextRetry(attempt int) retryState { - if attempt < 1 { - attempt = 1 - } - delay := m.cfg.RetryBaseDelay * time.Duration(1< m.cfg.RetryMaxDelay { - delay = m.cfg.RetryMaxDelay - } - delay = m.jitter(delay) - if delay < 0 { - delay = 0 - } - if delay > m.cfg.RetryMaxDelay { - delay = m.cfg.RetryMaxDelay - } - return retryState{attempt: attempt, next: m.clock().Add(delay)} -} -func (m *Manager) stop(running *activeRuntime) { - running.sink.accepting.Store(false) - running.cancel() - running.sink.wait(m.cfg.DrainTimeout) - select { - case <-running.done: - case <-time.After(m.cfg.DrainTimeout): - } - _ = m.store.ReleaseChannelLease(context.Background(), running.lease) - if m.observer != nil { - m.observer.AddChannelRuntime(running.instance.Type, "running", -1) - } -} -func (m *Manager) stopAll(active map[uuid.UUID]*activeRuntime) { - for _, running := range active { - m.stop(running) - } -} -func (m *Manager) setStatus(id uuid.UUID, token int64, state, code, message string) error { - return m.store.SetChannelStatus(context.Background(), model.ChannelRuntimeStatus{ChannelInstanceID: id, State: state, BackendInstanceID: m.owner, FencingToken: token, LastErrorCode: code, LastErrorMessage: message}) -} -func runRuntime(ctx context.Context, runtime channelcore.Channel) (err error) { - defer func() { - if recovered := recover(); recovered != nil { - err = fmt.Errorf("channel runtime panic (%T)\n%s", recovered, debug.Stack()) - } - }() - return runtime.Run(ctx) -} -func recoverInbound(next channelcore.InboundHandler) channelcore.InboundHandler { - return func(ctx context.Context, message channelcore.InboundMessage) (err error) { - defer func() { - if recovered := recover(); recovered != nil { - err = fmt.Errorf("channel message panic (%T)\n%s", recovered, debug.Stack()) - } - }() - return next(ctx, message) - } -} -func safeMessage(err error) string { - if err == nil { - return "" - } - return sanitize.ErrorMessage(err, 2048) -} - -type runtimeSink struct { - store repository.ChannelStore - instance model.ChannelInstance - accepting atomic.Bool - calls sync.WaitGroup - observer Observer - tracer trace.Tracer -} - -func (s *runtimeSink) Accept(ctx context.Context, message channelcore.InboundMessage) (channelcore.AcceptResult, error) { - ctx, span := s.tracer.Start(ctx, "channel.receive", trace.WithSpanKind(trace.SpanKindConsumer), trace.WithAttributes( - attribute.String("channel.type", boundedChannelType(s.instance.Type)), - attribute.String("messaging.conversation.type", boundedConversationType(message.Conversation.Type)), - )) - defer span.End() - if !s.accepting.Load() { - s.observe("rejected") - span.SetAttributes(attribute.String("mooncode.result", "rejected")) - return "", context.Canceled - } - s.calls.Add(1) - defer s.calls.Done() - if !s.accepting.Load() { - s.observe("rejected") - span.SetAttributes(attribute.String("mooncode.result", "rejected")) - return "", context.Canceled - } - inserted, err := s.store.AcceptInbound(ctx, s.instance, message) - if err != nil { - s.observe("failed") - span.RecordError(err) - span.SetStatus(codes.Error, "message persistence failed") - span.SetAttributes(attribute.String("mooncode.result", "failed")) - return "", err - } - if !inserted { - s.observe("duplicate") - span.SetAttributes(attribute.String("mooncode.result", "duplicate")) - return channelcore.AlreadyAccepted, nil - } - s.observe("accepted") - span.SetAttributes(attribute.String("mooncode.result", "accepted")) - return channelcore.Accepted, nil -} - -func boundedChannelType(value string) string { - if value == "feishu" || value == "dingtalk" { - return value - } - return "other" -} - -func boundedConversationType(value string) string { - if value == "direct" || value == "group" { - return value - } - return "other" -} -func (s *runtimeSink) observe(result string) { - if s.observer != nil { - s.observer.ObserveChannelMessage(s.instance.Type, result) - } -} -func (s *runtimeSink) wait(timeout time.Duration) { - done := make(chan struct{}) - go func() { s.calls.Wait(); close(done) }() - select { - case <-done: - case <-time.After(timeout): - } -} - -var _ channelcore.InboundSink = (*runtimeSink)(nil) diff --git a/internal/channel/manager_test.go b/internal/channel/manager_test.go deleted file mode 100644 index 32b771d..0000000 --- a/internal/channel/manager_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package channel - -import ( - "context" - "testing" - "time" - - "github.com/mooncode-ai/mooncode/internal/config" - channelcore "github.com/mooncode-ai/mooncode/pkg/channel" - "github.com/stretchr/testify/require" -) - -func TestManagerRetryBackoffCapsAndUsesJitter(t *testing.T) { - now := time.Unix(100, 0) - manager := &Manager{ - cfg: config.ChannelsConfig{RetryBaseDelay: time.Second, RetryMaxDelay: 8 * time.Second}, - clock: func() time.Time { return now }, - jitter: func(delay time.Duration) time.Duration { return delay + time.Second }, - } - require.Equal(t, now.Add(2*time.Second), manager.nextRetry(1).next) - require.Equal(t, now.Add(5*time.Second), manager.nextRetry(3).next) - require.Equal(t, now.Add(8*time.Second), manager.nextRetry(20).next) -} - -func TestRuntimePanicIsContained(t *testing.T) { - err := runRuntime(context.Background(), panicChannel{}) - require.ErrorContains(t, err, "channel runtime panic") -} - -func TestInboundPanicIsContained(t *testing.T) { - handler := recoverInbound(func(context.Context, channelcore.InboundMessage) error { panic("boom") }) - err := handler(t.Context(), channelcore.InboundMessage{}) - require.ErrorContains(t, err, "channel message panic (string)") -} - -type panicChannel struct{} - -func (panicChannel) Type() string { return "fake" } -func (panicChannel) Run(context.Context) error { panic("boom") } - -var _ channelcore.Channel = panicChannel{} diff --git a/internal/channel/runtime/manager.go b/internal/channel/runtime/manager.go new file mode 100644 index 0000000..11f0e5a --- /dev/null +++ b/internal/channel/runtime/manager.go @@ -0,0 +1,234 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/uuid" +) + +const defaultPollInterval = 5 * time.Second + +type Store interface { + ListRuntimeChannels(context.Context) ([]Configuration, error) + SaveInbound(context.Context, Configuration, channel.InboundMessage) (bool, error) + MarkRuntimeConnected(context.Context, uuid.UUID, int64) error + MarkRuntimeError(context.Context, uuid.UUID, int64, string) error +} + +type CommandHandler interface { + Handle(context.Context, uuid.UUID, uuid.UUID, int64, channel.InboundMessage) (string, error) +} + +type Decryptor interface { + Decrypt([]byte, []byte, int) ([]byte, error) +} + +type Manager struct { + store Store + decryptor Decryptor + factories map[string]channel.Factory + pollInterval time.Duration + logger *slog.Logger + commands CommandHandler + active map[uuid.UUID]activeConnection +} + +type activeConnection struct { + version int64 + connection channel.Connection +} + +type startResult struct { + configuration Configuration + connection channel.Connection + err error +} + +func NewManager(store Store, decryptor Decryptor, factories []channel.Factory, options ...Option) *Manager { + registered := make(map[string]channel.Factory, len(factories)) + for _, factory := range factories { + registered[factory.Type()] = factory + } + manager := &Manager{ + store: store, decryptor: decryptor, factories: registered, + pollInterval: defaultPollInterval, logger: slog.Default(), active: make(map[uuid.UUID]activeConnection), + } + for _, option := range options { + option(manager) + } + + return manager +} + +func (m *Manager) Run(ctx context.Context) error { + m.reconcileAndLog(ctx) + ticker := time.NewTicker(m.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + m.closeAll() + return nil + case <-ticker.C: + m.reconcileAndLog(ctx) + } + } +} + +func (m *Manager) reconcileAndLog(ctx context.Context) { + if err := m.reconcile(ctx); err != nil && ctx.Err() == nil { + m.logger.ErrorContext(ctx, "channel runtime reconciliation failed", "error", err) + } +} + +func (m *Manager) reconcile(ctx context.Context) error { + configurations, err := m.store.ListRuntimeChannels(ctx) + if err != nil { + return fmt.Errorf("list runtime channels: %w", err) + } + desired := make(map[uuid.UUID]Configuration, len(configurations)) + for _, item := range configurations { + desired[item.ID] = item + } + + for id, running := range m.active { + item, enabled := desired[id] + if enabled && item.Version == running.version { + continue + } + if err := running.connection.Close(); err != nil { + m.logger.WarnContext(ctx, "close channel connection", "channel_id", id, "error", err) + } + delete(m.active, id) + } + + candidates := make([]Configuration, 0, len(configurations)) + for _, item := range configurations { + if _, running := m.active[item.ID]; !running { + candidates = append(candidates, item) + } + } + results := make(chan startResult, len(candidates)) + for _, item := range candidates { + go func(configuration Configuration) { + connection, startError := m.start(ctx, configuration) + results <- startResult{configuration: configuration, connection: connection, err: startError} + }(item) + } + for range candidates { + result := <-results + if result.err != nil { + m.recordError(ctx, result.configuration, result.err) + continue + } + if ctx.Err() != nil { + _ = result.connection.Close() + continue + } + m.active[result.configuration.ID] = activeConnection{version: result.configuration.Version, connection: result.connection} + } + + return nil +} + +func (m *Manager) start(ctx context.Context, item Configuration) (channel.Connection, error) { + factory, ok := m.factories[item.Type] + if !ok { + return nil, fmt.Errorf("channel type %q is not registered", item.Type) + } + plaintext, err := m.decryptor.Decrypt(item.Ciphertext, item.Nonce, item.KeyVersion) + if err != nil { + return nil, fmt.Errorf("decrypt channel credential: %w", err) + } + defer clear(plaintext) + + var credentials map[string]string + if err := json.Unmarshal(plaintext, &credentials); err != nil { + return nil, fmt.Errorf("decode channel credential: %w", err) + } + var config configuration + if err := json.Unmarshal(item.Config, &config); err != nil { + return nil, fmt.Errorf("decode channel config: %w", err) + } + secretKey := "app_secret" + if item.Type == "dingtalk" { + secretKey = "client_secret" + } + connection, err := factory.New(config.Values, credentials[secretKey]) + if err != nil { + return nil, err + } + handler := func(messageContext context.Context, message channel.InboundMessage) error { + normalized, accepted := applyPolicy(config, message) + if !accepted { + return nil + } + inserted, err := m.store.SaveInbound(messageContext, item, normalized) + if err != nil || !inserted || m.commands == nil { + return err + } + response, commandErr := m.commands.Handle(messageContext, item.WorkspaceID, item.ID, item.Version, normalized) + if commandErr != nil { + m.logger.ErrorContext(messageContext, "handle channel command", "channel_id", item.ID, "message_id", normalized.ExternalID, "error", commandErr) + response = "MoonCode could not complete that command. Please try again later." + } + if strings.TrimSpace(response) == "" { + return nil + } + targetID := normalized.ConversationID + if normalized.ConversationType == "direct" && item.Type == "dingtalk" { + targetID = rawSenderID(normalized.SenderCanonicalID) + } + if err := connection.Send(messageContext, channel.Message{Text: response, TargetID: targetID, TargetType: normalized.ConversationType}); err != nil { + m.logger.ErrorContext(messageContext, "send channel command response", "channel_id", item.ID, "message_id", normalized.ExternalID, "error", err) + } + + return nil + } + if err := connection.Start(ctx, handler); err != nil { + _ = connection.Close() + return nil, err + } + if err := m.store.MarkRuntimeConnected(ctx, item.ID, item.Version); err != nil { + _ = connection.Close() + return nil, fmt.Errorf("mark channel connected: %w", err) + } + + return connection, nil +} + +func rawSenderID(canonical string) string { + _, value, found := strings.Cut(canonical, ":") + if found { + return value + } + + return canonical +} + +func (m *Manager) recordError(ctx context.Context, item Configuration, cause error) { + message := strings.TrimSpace(cause.Error()) + if len(message) > 1000 { + message = message[:1000] + } + if err := m.store.MarkRuntimeError(ctx, item.ID, item.Version, message); err != nil { + m.logger.ErrorContext(ctx, "record channel runtime error", "channel_id", item.ID, "error", err) + } + m.logger.WarnContext(ctx, "channel connection unavailable", "channel_id", item.ID, "channel_type", item.Type, "error", cause) +} + +func (m *Manager) closeAll() { + for id, running := range m.active { + if err := running.connection.Close(); err != nil { + m.logger.Warn("close channel connection", "channel_id", id, "error", err) + } + delete(m.active, id) + } +} diff --git a/internal/channel/runtime/manager_test.go b/internal/channel/runtime/manager_test.go new file mode 100644 index 0000000..9a22d82 --- /dev/null +++ b/internal/channel/runtime/manager_test.go @@ -0,0 +1,205 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/uuid" +) + +type runtimeStore struct { + mu sync.Mutex + configurations []Configuration + connected []int64 + errors []string + messages []channel.InboundMessage + seen map[string]struct{} +} + +func (s *runtimeStore) ListRuntimeChannels(context.Context) ([]Configuration, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return append([]Configuration(nil), s.configurations...), nil +} + +func (s *runtimeStore) SaveInbound(_ context.Context, _ Configuration, message channel.InboundMessage) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.seen == nil { + s.seen = make(map[string]struct{}) + } + if _, duplicate := s.seen[message.ExternalID]; duplicate { + return false, nil + } + s.seen[message.ExternalID] = struct{}{} + s.messages = append(s.messages, message) + + return true, nil +} + +func (s *runtimeStore) MarkRuntimeConnected(_ context.Context, _ uuid.UUID, version int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.connected = append(s.connected, version) + + return nil +} + +func (s *runtimeStore) MarkRuntimeError(_ context.Context, _ uuid.UUID, _ int64, message string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.errors = append(s.errors, message) + + return nil +} + +type runtimeDecryptor struct{ plaintext []byte } + +func (d runtimeDecryptor) Decrypt([]byte, []byte, int) ([]byte, error) { + return append([]byte(nil), d.plaintext...), nil +} + +type runtimeFactory struct { + mu sync.Mutex + connections []*runtimeConnection + messages []channel.InboundMessage + startError error +} + +func (*runtimeFactory) Type() string { return "feishu" } + +func (f *runtimeFactory) New(map[string]any, string) (channel.Connection, error) { + f.mu.Lock() + defer f.mu.Unlock() + connection := &runtimeConnection{messages: append([]channel.InboundMessage(nil), f.messages...), startError: f.startError} + f.connections = append(f.connections, connection) + + return connection, nil +} + +type runtimeConnection struct { + messages []channel.InboundMessage + sent []channel.Message + startError error + closed bool +} + +func (c *runtimeConnection) Send(_ context.Context, message channel.Message) error { + c.sent = append(c.sent, message) + + return nil +} + +func (c *runtimeConnection) Start(ctx context.Context, handler channel.Handler) error { + if c.startError != nil { + return c.startError + } + for _, message := range c.messages { + if err := handler(ctx, message); err != nil { + return err + } + } + + return nil +} + +func (c *runtimeConnection) Close() error { + c.closed = true + return nil +} + +func TestManagerReconcilesLifecycleAndInboundIdempotency(t *testing.T) { + channelID := uuid.New() + store := &runtimeStore{configurations: []Configuration{{ + ID: channelID, WorkspaceID: uuid.New(), Type: "feishu", Version: 1, + Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["allowed"],"groupPolicy":{"requireMention":true,"prefix":"/moon"}}`), + }}} + factory := &runtimeFactory{messages: []channel.InboundMessage{ + {ExternalID: "accepted", SenderCanonicalID: "feishu:allowed", ConversationType: "group", Text: "/moon analyze", OccurredAt: time.Now()}, + {ExternalID: "denied", SenderCanonicalID: "feishu:other", ConversationType: "direct", Text: "analyze", OccurredAt: time.Now()}, + {ExternalID: "accepted", SenderCanonicalID: "feishu:allowed", ConversationType: "group", Text: "/moon analyze", OccurredAt: time.Now()}, + }} + manager := NewManager(store, runtimeDecryptor{plaintext: []byte(`{"app_secret":"secret"}`)}, []channel.Factory{factory}) + + if err := manager.reconcile(context.Background()); err != nil { + t.Fatal(err) + } + if len(store.connected) != 1 || len(store.messages) != 1 || store.messages[0].Text != "analyze" { + t.Fatalf("unexpected first reconciliation: connected=%v messages=%+v", store.connected, store.messages) + } + first := factory.connections[0] + store.configurations[0].Version = 2 + if err := manager.reconcile(context.Background()); err != nil { + t.Fatal(err) + } + if !first.closed || len(factory.connections) != 2 || len(store.connected) != 2 { + t.Fatalf("expected version change to replace connection: closed=%v connections=%d connected=%v", first.closed, len(factory.connections), store.connected) + } + + store.configurations = nil + if err := manager.reconcile(context.Background()); err != nil { + t.Fatal(err) + } + if !factory.connections[1].closed || len(manager.active) != 0 { + t.Fatal("expected disabled channel to be closed") + } +} + +func TestManagerIsolatesConnectionFailure(t *testing.T) { + failingFactory := &runtimeFactory{startError: errors.New("handshake failed")} + store := &runtimeStore{configurations: []Configuration{{ + ID: uuid.New(), WorkspaceID: uuid.New(), Type: "feishu", Version: 1, + Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`), + }}} + manager := NewManager(store, runtimeDecryptor{plaintext: []byte(`{"app_secret":"secret"}`)}, []channel.Factory{failingFactory}) + + if err := manager.reconcile(context.Background()); err != nil { + t.Fatal(err) + } + if len(store.errors) != 1 || len(manager.active) != 0 { + t.Fatalf("expected isolated failure, errors=%v active=%d", store.errors, len(manager.active)) + } +} + +type runtimeCommands struct { + workspaceID uuid.UUID + channelID uuid.UUID + message channel.InboundMessage +} + +func (c *runtimeCommands) Handle(_ context.Context, workspaceID, channelID uuid.UUID, _ int64, message channel.InboundMessage) (string, error) { + c.workspaceID, c.channelID, c.message = workspaceID, channelID, message + + return "analysis queued", nil +} + +func TestManagerHandlesInsertedMessageAndRepliesToConversation(t *testing.T) { + channelID, workspaceID := uuid.New(), uuid.New() + store := &runtimeStore{configurations: []Configuration{{ + ID: channelID, WorkspaceID: workspaceID, Type: "feishu", Version: 1, + Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`), + }}} + factory := &runtimeFactory{messages: []channel.InboundMessage{{ + ExternalID: "command", ConversationID: "chat-42", ConversationType: "group", + SenderCanonicalID: "feishu:sender", Text: "analyze", OccurredAt: time.Now(), + }}} + commands := &runtimeCommands{} + manager := NewManager(store, runtimeDecryptor{plaintext: []byte(`{"app_secret":"secret"}`)}, []channel.Factory{factory}, WithCommandHandler(commands)) + + if err := manager.reconcile(context.Background()); err != nil { + t.Fatal(err) + } + connection := factory.connections[0] + if commands.workspaceID != workspaceID || commands.channelID != channelID || commands.message.ExternalID != "command" { + t.Fatalf("command handler received unexpected context: %+v", commands) + } + if len(connection.sent) != 1 || connection.sent[0].Text != "analysis queued" || connection.sent[0].TargetID != "chat-42" || connection.sent[0].TargetType != "group" { + t.Fatalf("unexpected command reply: %+v", connection.sent) + } +} diff --git a/internal/channel/runtime/model.go b/internal/channel/runtime/model.go new file mode 100644 index 0000000..c855dea --- /dev/null +++ b/internal/channel/runtime/model.go @@ -0,0 +1,29 @@ +package runtime + +import ( + "encoding/json" + + "github.com/google/uuid" +) + +type Configuration struct { + ID uuid.UUID + WorkspaceID uuid.UUID + Type string + Version int64 + Ciphertext []byte + Nonce []byte + KeyVersion int + Config json.RawMessage +} + +type configuration struct { + Values map[string]any `json:"values"` + SenderAllowList []string `json:"senderAllowList"` + GroupPolicy groupPolicy `json:"groupPolicy"` +} + +type groupPolicy struct { + RequireMention bool `json:"requireMention"` + Prefix string `json:"prefix"` +} diff --git a/internal/channel/runtime/options.go b/internal/channel/runtime/options.go new file mode 100644 index 0000000..af749fe --- /dev/null +++ b/internal/channel/runtime/options.go @@ -0,0 +1,28 @@ +package runtime + +import ( + "log/slog" + "time" +) + +type Option func(*Manager) + +func WithPollInterval(interval time.Duration) Option { + return func(manager *Manager) { + if interval > 0 { + manager.pollInterval = interval + } + } +} + +func WithLogger(logger *slog.Logger) Option { + return func(manager *Manager) { + if logger != nil { + manager.logger = logger + } + } +} + +func WithCommandHandler(handler CommandHandler) Option { + return func(manager *Manager) { manager.commands = handler } +} diff --git a/internal/channel/runtime/policy.go b/internal/channel/runtime/policy.go new file mode 100644 index 0000000..9c13801 --- /dev/null +++ b/internal/channel/runtime/policy.go @@ -0,0 +1,47 @@ +package runtime + +import ( + "strings" + + "github.com/fuchencong/mooncode/pkg/channel" +) + +func applyPolicy(config configuration, message channel.InboundMessage) (channel.InboundMessage, bool) { + if !senderAllowed(config.SenderAllowList, message.SenderCanonicalID) { + return channel.InboundMessage{}, false + } + + message.Text = strings.TrimSpace(message.Text) + if message.ConversationType != "group" { + return message, message.Text != "" + } + if message.Mentioned { + return message, message.Text != "" + } + + prefix := strings.TrimSpace(config.GroupPolicy.Prefix) + if prefix != "" && strings.HasPrefix(message.Text, prefix) { + message.Text = strings.TrimSpace(strings.TrimPrefix(message.Text, prefix)) + return message, message.Text != "" + } + if config.GroupPolicy.RequireMention { + return channel.InboundMessage{}, false + } + + return message, message.Text != "" +} + +func senderAllowed(allowlist []string, canonicalID string) bool { + rawID := canonicalID + if separator := strings.IndexByte(canonicalID, ':'); separator >= 0 { + rawID = canonicalID[separator+1:] + } + for _, allowed := range allowlist { + allowed = strings.TrimSpace(allowed) + if allowed == "*" || allowed == canonicalID || allowed == rawID { + return true + } + } + + return false +} diff --git a/internal/channel/runtime/policy_test.go b/internal/channel/runtime/policy_test.go new file mode 100644 index 0000000..0cb458f --- /dev/null +++ b/internal/channel/runtime/policy_test.go @@ -0,0 +1,43 @@ +package runtime + +import ( + "testing" + + "github.com/fuchencong/mooncode/pkg/channel" +) + +func TestApplyPolicy(t *testing.T) { + config := configuration{ + SenderAllowList: []string{"ou_allowed"}, + GroupPolicy: groupPolicy{RequireMention: true, Prefix: "/moon"}, + } + tests := []struct { + name string + message channel.InboundMessage + want string + accept bool + }{ + {name: "allowed direct", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_allowed", ConversationType: "direct", Text: " hello "}, want: "hello", accept: true}, + {name: "denied sender", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_denied", ConversationType: "direct", Text: "hello"}}, + {name: "mentioned group", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_allowed", ConversationType: "group", Text: "analyze", Mentioned: true}, want: "analyze", accept: true}, + {name: "prefixed group", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_allowed", ConversationType: "group", Text: "/moon analyze"}, want: "analyze", accept: true}, + {name: "ordinary group", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_allowed", ConversationType: "group", Text: "hello"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + message, accepted := applyPolicy(config, test.message) + if accepted != test.accept || message.Text != test.want { + t.Fatalf("applyPolicy() = (%q, %v), want (%q, %v)", message.Text, accepted, test.want, test.accept) + } + }) + } +} + +func TestApplyPolicyExplicitWildcard(t *testing.T) { + config := configuration{SenderAllowList: []string{"*"}} + message, accepted := applyPolicy(config, channel.InboundMessage{SenderCanonicalID: "dingtalk:anyone", ConversationType: "group", Text: "hello"}) + if !accepted || message.Text != "hello" { + t.Fatalf("unexpected wildcard result: %+v, accepted=%v", message, accepted) + } +} diff --git a/internal/channel/workflow/model.go b/internal/channel/workflow/model.go new file mode 100644 index 0000000..45dcd3a --- /dev/null +++ b/internal/channel/workflow/model.go @@ -0,0 +1,28 @@ +package workflow + +import ( + "encoding/json" + + "github.com/google/uuid" +) + +type WorkItem struct { + NotificationID uuid.UUID + Status string + EventType string + WorkspaceID uuid.UUID + AnalysisRunID uuid.UUID + ChannelID uuid.UUID + ChannelType string + ChannelName string + ChannelEnabled bool + Ciphertext []byte + Nonce []byte + KeyVersion int + Config json.RawMessage + RepositoryName string + CommitSHA string + FailedStage string + ErrorMessage string + Result json.RawMessage +} diff --git a/internal/channel/workflow/runner.go b/internal/channel/workflow/runner.go new file mode 100644 index 0000000..9de49dc --- /dev/null +++ b/internal/channel/workflow/runner.go @@ -0,0 +1,161 @@ +package workflow + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + channelbiz "github.com/fuchencong/mooncode/internal/channel/biz" + workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type Store interface { + LoadDelivery(context.Context, uuid.UUID) (WorkItem, error) + StartDelivery(context.Context, uuid.UUID) error + FinishDelivery(context.Context, uuid.UUID) error + CancelDelivery(context.Context, uuid.UUID) error + FailDelivery(context.Context, uuid.UUID, string) error +} + +type Decryptor interface { + Decrypt([]byte, []byte, int) ([]byte, error) +} + +type Runner struct { + store Store + decryptor Decryptor + factories map[string]channelpkg.Factory +} + +func NewRunner(store Store, decryptor Decryptor, factories ...channelpkg.Factory) *Runner { + registered := make(map[string]channelpkg.Factory, len(factories)) + for _, factory := range factories { + registered[factory.Type()] = factory + } + + return &Runner{store: store, decryptor: decryptor, factories: registered} +} + +func (r *Runner) Execute(ctx context.Context, notificationID uuid.UUID) error { + work, err := r.store.LoadDelivery(ctx, notificationID) + if err != nil { + return err + } + if work.Status == "queued" { + if err = r.store.StartDelivery(ctx, notificationID); err != nil { + return err + } + } else if work.Status != "running" { + return nil + } + if !work.ChannelEnabled { + return r.store.CancelDelivery(ctx, notificationID) + } + + factory, ok := r.factories[work.ChannelType] + if !ok { + return workflowbiz.Permanent(fmt.Errorf("channel type %q is not registered", work.ChannelType)) + } + plaintext, err := r.decryptor.Decrypt(work.Ciphertext, work.Nonce, work.KeyVersion) + if err != nil { + return workflowbiz.Permanent(fmt.Errorf("decrypt channel credential: %w", err)) + } + defer clear(plaintext) + var credentials map[string]string + if err := json.Unmarshal(plaintext, &credentials); err != nil { + return workflowbiz.Permanent(fmt.Errorf("decode channel credential: %w", err)) + } + var config struct { + Values map[string]any `json:"values"` + } + if err := json.Unmarshal(work.Config, &config); err != nil { + return workflowbiz.Permanent(fmt.Errorf("decode channel config: %w", err)) + } + secretKey := "app_secret" + if work.ChannelType == "dingtalk" { + secretKey = "client_secret" + } + sender, err := factory.New(config.Values, credentials[secretKey]) + if err != nil { + return workflowbiz.Permanent(err) + } + defer func() { _ = sender.Close() }() + if err := sender.Send(ctx, channelpkg.Message{Text: notificationText(work)}); err != nil { + if channelpkg.IsPermanent(err) { + return workflowbiz.Permanent(err) + } + + return err + } + + return r.store.FinishDelivery(ctx, notificationID) +} + +func (r *Runner) MarkFailed(ctx context.Context, notificationID uuid.UUID, cause error) error { + if _, err := r.store.LoadDelivery(ctx, notificationID); errors.Is(err, pgx.ErrNoRows) { + return nil + } else if err != nil { + return err + } + message := "notification delivery failed" + if cause != nil { + message = cause.Error() + } + if len(message) > 1000 { + message = message[:1000] + } + + return r.store.FailDelivery(ctx, notificationID, message) +} + +func notificationText(work WorkItem) string { + switch work.EventType { + case channelbiz.EventAnalysisStarted: + return fmt.Sprintf( + "MoonCode analysis started\nRepository: %s\nCommit: %.12s", + work.RepositoryName, + work.CommitSHA, + ) + case channelbiz.EventAnalysisFailed: + return fmt.Sprintf( + "MoonCode analysis failed\nRepository: %s\nCommit: %.12s\nStage: %s\nError: %s", + work.RepositoryName, + work.CommitSHA, + work.FailedStage, + work.ErrorMessage, + ) + } + + var result struct { + Summary struct { + Files int64 `json:"files"` + Code int64 `json:"code"` + Comments int64 `json:"comments"` + Complexity int64 `json:"complexity"` + } `json:"summary"` + Languages []struct { + Name string `json:"name"` + } `json:"languages"` + } + _ = json.Unmarshal(work.Result, &result) + languages := make([]string, 0, min(5, len(result.Languages))) + for _, language := range result.Languages[:min(5, len(result.Languages))] { + languages = append(languages, language.Name) + } + + return fmt.Sprintf( + "MoonCode analysis completed\nRepository: %s\nCommit: %.12s\nFiles: %d · Code: %d · Comments: %d · Complexity: %d\nLanguages: %s", + work.RepositoryName, + work.CommitSHA, + result.Summary.Files, + result.Summary.Code, + result.Summary.Comments, + result.Summary.Complexity, + strings.Join(languages, ", "), + ) +} diff --git a/internal/channel/workflow/runner_test.go b/internal/channel/workflow/runner_test.go new file mode 100644 index 0000000..88b2dea --- /dev/null +++ b/internal/channel/workflow/runner_test.go @@ -0,0 +1,140 @@ +package workflow + +import ( + "context" + "encoding/json" + "strings" + "testing" + + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/uuid" +) + +type deliveryStore struct { + work WorkItem + started bool + finished bool + cancelled bool + failed string +} + +func (s *deliveryStore) LoadDelivery(context.Context, uuid.UUID) (WorkItem, error) { + return s.work, nil +} +func (s *deliveryStore) StartDelivery(context.Context, uuid.UUID) error { + s.started = true + return nil +} +func (s *deliveryStore) FinishDelivery(context.Context, uuid.UUID) error { + s.finished = true + return nil +} +func (s *deliveryStore) CancelDelivery(context.Context, uuid.UUID) error { + s.cancelled = true + return nil +} +func (s *deliveryStore) FailDelivery(_ context.Context, _ uuid.UUID, message string) error { + s.failed = message + return nil +} + +type decryptor struct{ plaintext []byte } + +func (d decryptor) Decrypt([]byte, []byte, int) ([]byte, error) { return d.plaintext, nil } + +type senderFactory struct{ sender *messageSender } + +func (senderFactory) Type() string { return "feishu" } +func (f senderFactory) New(config map[string]any, secret string) (channelpkg.Connection, error) { + if config["app_id"] != "app" || secret != "secret" { + panic("unexpected channel configuration") + } + return f.sender, nil +} + +type messageSender struct{ text string } + +func (s *messageSender) Send(_ context.Context, message channelpkg.Message) error { + s.text = message.Text + return nil +} +func (*messageSender) Start(context.Context, channelpkg.Handler) error { return nil } +func (*messageSender) Close() error { return nil } + +func TestRunnerDeliversAnalysisSummary(t *testing.T) { + notificationID := uuid.New() + store := &deliveryStore{work: WorkItem{ + NotificationID: notificationID, + Status: "queued", + EventType: "analysis.succeeded", + ChannelType: "feishu", + ChannelEnabled: true, + Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"}}`), + RepositoryName: "mooncode", + CommitSHA: "1234567890abcdef", + Result: json.RawMessage(`{"summary":{"files":12,"code":345,"comments":20,"complexity":7},"languages":[{"name":"Go"}]}`), + }} + sender := &messageSender{} + runner := NewRunner(store, decryptor{plaintext: []byte(`{"app_secret":"secret"}`)}, senderFactory{sender: sender}) + + if err := runner.Execute(context.Background(), notificationID); err != nil { + t.Fatal(err) + } + if !store.started || !store.finished || store.cancelled { + t.Fatalf("unexpected delivery state: started=%v finished=%v cancelled=%v", store.started, store.finished, store.cancelled) + } + for _, expected := range []string{"mooncode", "1234567890ab", "Files: 12", "Code: 345", "Go"} { + if !strings.Contains(sender.text, expected) { + t.Fatalf("message %q does not contain %q", sender.text, expected) + } + } +} + +func TestNotificationTextProjectsLifecycleEvents(t *testing.T) { + started := notificationText(WorkItem{EventType: "analysis.started", RepositoryName: "mooncode", CommitSHA: "1234567890abcdef"}) + for _, expected := range []string{"analysis started", "mooncode", "1234567890ab"} { + if !strings.Contains(started, expected) { + t.Fatalf("started message %q does not contain %q", started, expected) + } + } + + failed := notificationText(WorkItem{ + EventType: "analysis.failed", + RepositoryName: "mooncode", + CommitSHA: "1234567890abcdef", + FailedStage: "analyze", + ErrorMessage: "Analyzer execution failed", + }) + for _, expected := range []string{"analysis failed", "analyze", "Analyzer execution failed"} { + if !strings.Contains(failed, expected) { + t.Fatalf("failed message %q does not contain %q", failed, expected) + } + } +} + +func TestRunnerCancelsDisabledChannel(t *testing.T) { + notificationID := uuid.New() + store := &deliveryStore{work: WorkItem{NotificationID: notificationID, Status: "queued"}} + runner := NewRunner(store, decryptor{}) + + if err := runner.Execute(context.Background(), notificationID); err != nil { + t.Fatal(err) + } + if !store.started || !store.cancelled || store.finished { + t.Fatalf("unexpected delivery state: started=%v finished=%v cancelled=%v", store.started, store.finished, store.cancelled) + } +} + +func TestRunnerSkipsTerminalDelivery(t *testing.T) { + notificationID := uuid.New() + store := &deliveryStore{work: WorkItem{NotificationID: notificationID, Status: "delivered"}} + sender := &messageSender{} + runner := NewRunner(store, decryptor{}, senderFactory{sender: sender}) + + if err := runner.Execute(context.Background(), notificationID); err != nil { + t.Fatal(err) + } + if store.started || store.finished || store.cancelled || sender.text != "" { + t.Fatal("terminal notification delivery was executed again") + } +} diff --git a/internal/checkout/store.go b/internal/checkout/store.go deleted file mode 100644 index 5805f6f..0000000 --- a/internal/checkout/store.go +++ /dev/null @@ -1,139 +0,0 @@ -// Package checkout manages the current source tree of a repository. -package checkout - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "sync" - - "github.com/google/uuid" -) - -// Populate writes a complete repository checkout to directory. The directory -// does not exist before Populate is called. -type Populate func(ctx context.Context, directory string) error - -// Store keeps exactly one current checkout for each repository. -type Store interface { - Replace(ctx context.Context, repositoryID uuid.UUID, populate Populate) (string, error) - Path(repositoryID uuid.UUID) string - Remove(ctx context.Context, repositoryID uuid.UUID) error -} - -type Option func(*FilesystemStore) - -// WithDirectoryMode changes the permissions used when creating store-owned -// directories. The default is 0750. -func WithDirectoryMode(mode os.FileMode) Option { - return func(store *FilesystemStore) { - if mode.Perm() != 0 { - store.directoryMode = mode.Perm() - } - } -} - -// FilesystemStore persists checkouts below one filesystem root. Replacement -// uses same-filesystem renames so readers never observe a partially populated -// checkout. -type FilesystemStore struct { - root string - directoryMode os.FileMode - mu sync.Mutex -} - -func NewFilesystemStore(root string, opts ...Option) (*FilesystemStore, error) { - if root == "" { - return nil, errors.New("checkout root is required") - } - absolute, err := filepath.Abs(root) - if err != nil { - return nil, fmt.Errorf("resolve checkout root: %w", err) - } - store := &FilesystemStore{root: filepath.Clean(absolute), directoryMode: 0o750} - for _, option := range opts { - if option != nil { - option(store) - } - } - if err := os.RemoveAll(store.stagingRoot()); err != nil { - return nil, fmt.Errorf("clean checkout staging directory: %w", err) - } - if err := os.MkdirAll(store.stagingRoot(), store.directoryMode); err != nil { - return nil, fmt.Errorf("create checkout root: %w", err) - } - return store, nil -} - -func (s *FilesystemStore) Replace(ctx context.Context, repositoryID uuid.UUID, populate Populate) (string, error) { - if repositoryID == uuid.Nil { - return "", errors.New("repository ID is required") - } - if populate == nil { - return "", errors.New("checkout populate function is required") - } - if err := ctx.Err(); err != nil { - return "", err - } - s.mu.Lock() - defer s.mu.Unlock() - - staged := filepath.Join(s.stagingRoot(), repositoryID.String()+"-"+uuid.NewString()) - defer func() { _ = os.RemoveAll(staged) }() - if err := populate(ctx, staged); err != nil { - return "", err - } - if err := ctx.Err(); err != nil { - return "", err - } - - target := s.Path(repositoryID) - previous := filepath.Join(s.stagingRoot(), repositoryID.String()+"-previous-"+uuid.NewString()) - hadPrevious := false - if _, err := os.Stat(target); err == nil { - if err := os.Rename(target, previous); err != nil { - return "", fmt.Errorf("move previous checkout: %w", err) - } - hadPrevious = true - } else if !errors.Is(err, os.ErrNotExist) { - return "", fmt.Errorf("inspect current checkout: %w", err) - } - - if err := os.Rename(staged, target); err != nil { - if hadPrevious { - _ = os.Rename(previous, target) - } - return "", fmt.Errorf("activate checkout: %w", err) - } - if hadPrevious { - _ = os.RemoveAll(previous) - } - return target, nil -} - -func (s *FilesystemStore) Path(repositoryID uuid.UUID) string { - return filepath.Join(s.root, repositoryID.String()) -} - -func (s *FilesystemStore) Remove(ctx context.Context, repositoryID uuid.UUID) error { - if repositoryID == uuid.Nil { - return errors.New("repository ID is required") - } - if err := ctx.Err(); err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - if err := os.RemoveAll(s.Path(repositoryID)); err != nil { - return fmt.Errorf("remove repository checkout: %w", err) - } - return nil -} - -func (s *FilesystemStore) stagingRoot() string { - return filepath.Join(s.root, ".staging") -} - -var _ Store = (*FilesystemStore)(nil) diff --git a/internal/checkout/store_test.go b/internal/checkout/store_test.go deleted file mode 100644 index a16f118..0000000 --- a/internal/checkout/store_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package checkout - -import ( - "context" - "errors" - "os" - "path/filepath" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func TestFilesystemStoreReplacesCheckout(t *testing.T) { - store, err := NewFilesystemStore(t.TempDir()) - require.NoError(t, err) - repositoryID := uuid.New() - - write := func(content string) Populate { - return func(_ context.Context, directory string) error { - require.NoError(t, os.MkdirAll(directory, 0o750)) - return os.WriteFile(filepath.Join(directory, "README.md"), []byte(content), 0o600) - } - } - - _, err = store.Replace(t.Context(), repositoryID, write("first")) - require.NoError(t, err) - path, err := store.Replace(t.Context(), repositoryID, write("second")) - require.NoError(t, err) - content, err := os.ReadFile(filepath.Join(path, "README.md")) - require.NoError(t, err) - require.Equal(t, "second", string(content)) - - require.NoError(t, store.Remove(t.Context(), repositoryID)) - _, err = os.Stat(path) - require.ErrorIs(t, err, os.ErrNotExist) -} - -func TestFilesystemStoreKeepsCurrentCheckoutWhenPopulateFails(t *testing.T) { - store, err := NewFilesystemStore(t.TempDir()) - require.NoError(t, err) - repositoryID := uuid.New() - _, err = store.Replace(t.Context(), repositoryID, func(_ context.Context, directory string) error { - require.NoError(t, os.MkdirAll(directory, 0o750)) - return os.WriteFile(filepath.Join(directory, "current"), []byte("ok"), 0o600) - }) - require.NoError(t, err) - - expected := errors.New("clone failed") - _, err = store.Replace(t.Context(), repositoryID, func(context.Context, string) error { return expected }) - require.ErrorIs(t, err, expected) - content, err := os.ReadFile(filepath.Join(store.Path(repositoryID), "current")) - require.NoError(t, err) - require.Equal(t, "ok", string(content)) -} diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 0000000..5b575dd --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,115 @@ +package cli + +import ( + "context" + "fmt" + + analysisbiz "github.com/fuchencong/mooncode/internal/analysis/biz" + analysisdata "github.com/fuchencong/mooncode/internal/analysis/data" + analysisworkflow "github.com/fuchencong/mooncode/internal/analysis/workflow" + channelbiz "github.com/fuchencong/mooncode/internal/channel/biz" + channelcommand "github.com/fuchencong/mooncode/internal/channel/command" + channeldata "github.com/fuchencong/mooncode/internal/channel/data" + channelruntime "github.com/fuchencong/mooncode/internal/channel/runtime" + channelworkflow "github.com/fuchencong/mooncode/internal/channel/workflow" + identitybiz "github.com/fuchencong/mooncode/internal/identity/biz" + identitydata "github.com/fuchencong/mooncode/internal/identity/data" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/config" + "github.com/fuchencong/mooncode/internal/platform/database" + platformhatchet "github.com/fuchencong/mooncode/internal/platform/hatchet" + "github.com/fuchencong/mooncode/internal/platform/httpserver" + "github.com/fuchencong/mooncode/internal/platform/secret" + repositorybiz "github.com/fuchencong/mooncode/internal/repository/biz" + repositorydata "github.com/fuchencong/mooncode/internal/repository/data" + repositoryworkflow "github.com/fuchencong/mooncode/internal/repository/workflow" + retentiondata "github.com/fuchencong/mooncode/internal/retention/data" + retentionworkflow "github.com/fuchencong/mooncode/internal/retention/workflow" + workflow "github.com/fuchencong/mooncode/internal/workflow" + workflowdata "github.com/fuchencong/mooncode/internal/workflow/data" + "github.com/fuchencong/mooncode/pkg/analyzer/scc" + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/fuchencong/mooncode/pkg/channel/dingtalk" + "github.com/fuchencong/mooncode/pkg/channel/feishu" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/jackc/pgx/v5/pgxpool" +) + +type app struct { + config config.Config + pool *pgxpool.Pool + server *httpserver.Server + hatchet *platformhatchet.Client + dispatcher *workflow.Dispatcher + runtime *channelruntime.Manager + janitor gitrepo.WorktreeJanitor + metrics *httpserver.Metrics +} + +func build(ctx context.Context, cfg config.Config) (*app, error) { + pool, err := database.Open(ctx, cfg.Database.URL, cfg.Database.MaxConnections) + if err != nil { + return nil, err + } + fail := func(err error) (*app, error) { pool.Close(); return nil, err } + if cfg.Database.MigrateOnStart { + if err := database.Migrate(ctx, pool); err != nil { + return fail(err) + } + } + cipher, err := secret.NewAESGCM(cfg.Secrets.Key, cfg.Secrets.KeyVersion) + if err != nil { + return fail(err) + } + git, err := gitrepo.NewManager(cfg.Repository.Root, gitrepo.WithMaxMirrorBytes(cfg.Repository.MaxMirrorBytes)) + if err != nil { + return fail(err) + } + identityStore := identitydata.NewStore(pool) + identityService := identitybiz.NewService(identityStore, cfg.Auth.Issuer) + providerService := identitybiz.NewProviderService(identityStore, cipher, identitybiz.NewHTTPProviderValidator(nil)) + repositoryStore := repositorydata.NewStore(pool, git, repositorydata.WithMaxRepositoriesPerWorkspace(cfg.Repository.MaxPerWorkspace)) + analysisStore := analysisdata.NewStore(pool, analysisdata.WithMaxConcurrentRunsPerWorkspace(cfg.Analysis.MaxConcurrentPerWorkspace)) + channelStore := channeldata.NewStore(pool) + retentionStore := retentiondata.NewStore(pool) + metrics := httpserver.NewMetrics() + feishuFactory := feishu.New() + dingTalkFactory := dingtalk.New() + repositoryRunner := repositoryworkflow.NewRunner(repositoryStore, identityService, providerService, git) + analysisRunner := analysisworkflow.NewRunner(analysisStore, identityService, git, scc.New(scc.WithBinary(cfg.Analysis.SCCPath), scc.WithMaxOutputBytes(cfg.Analysis.MaxOutputBytes)), cfg.Analysis.Timeout) + notificationRunner := channelworkflow.NewRunner(channelStore, cipher, feishuFactory, dingTalkFactory) + retentionScheduler := retentionworkflow.NewScheduler(retentionStore) + retentionRunner := retentionworkflow.NewRunner(retentionStore, git, retentionworkflow.WithMetrics(metrics)) + hatchetClient, err := platformhatchet.New( + cfg.Hatchet.Token, cfg.Hatchet.Address, cfg.Hatchet.Namespace, + repositoryRunner, analysisRunner, notificationRunner, retentionScheduler, retentionRunner, + platformhatchet.WithMetrics(metrics), + ) + if err != nil { + return fail(fmt.Errorf("create Hatchet client: %w", err)) + } + repositoryService := repositorybiz.NewService(repositoryStore, identityService, providerService, hatchetClient, git) + analysisService := analysisbiz.NewService(analysisStore, identityService, repositoryStore, hatchetClient) + channelService := channelbiz.NewService(channelStore, identityService, cipher) + commandService := channelcommand.NewService(channelStore, identityService, repositoryService, analysisService, cfg.Auth.AppURL) + server, err := httpserver.New(httpserver.Config{ + LogoutURL: cfg.Auth.LogoutURL, + RegistrationMode: cfg.Auth.RegistrationMode, TermsVersion: cfg.Auth.TermsVersion, PrivacyVersion: cfg.Auth.PrivacyVersion, + TrustedProxyCIDRs: cfg.Auth.TrustedProxyCIDRs, + MaxBodyBytes: cfg.HTTP.MaxBodyBytes, RequestTimeout: cfg.HTTP.RequestTimeout, + }, identityService, providerService, repositoryService, analysisService, channelService, commandService, auth.NewCSRF(cfg.Auth.CSRFKey), + httpserver.WithReadiness(database.NewReadiness(pool)), httpserver.WithMetrics(metrics), + ) + if err != nil { + return fail(fmt.Errorf("create HTTP server: %w", err)) + } + dispatcher := workflow.NewDispatcher(workflowdata.NewStore(pool), hatchetClient) + runtime := channelruntime.NewManager(channelStore, cipher, []channelpkg.Factory{feishuFactory, dingTalkFactory}, channelruntime.WithCommandHandler(commandService)) + + return &app{ + config: cfg, pool: pool, server: server, hatchet: hatchetClient, + dispatcher: dispatcher, runtime: runtime, janitor: git, metrics: metrics, + }, nil +} + +func (a *app) close() { a.pool.Close() } diff --git a/internal/cli/root.go b/internal/cli/root.go index 5c42664..fcc09d2 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,131 +2,231 @@ package cli import ( "context" + "errors" "fmt" "net/http" + "os/signal" + "syscall" "time" - "github.com/mooncode-ai/mooncode/internal/bootstrap" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/repository/postgres" - "github.com/mooncode-ai/mooncode/internal/version" + "github.com/fuchencong/mooncode/internal/platform/config" + "github.com/fuchencong/mooncode/internal/platform/database" + "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/spf13/cobra" ) -func NewRootCommand(info version.Info) *cobra.Command { - root := &cobra.Command{ - Use: "mooncode", - Short: "MoonCode backend", - SilenceErrors: true, - SilenceUsage: true, +func NewRoot() *cobra.Command { + var configPath string + root := &cobra.Command{Use: "mooncode", Short: "MoonCode code quality analysis SaaS"} + root.PersistentFlags().StringVar(&configPath, "config", "", "configuration file") + root.AddCommand(&cobra.Command{Use: "serve", Short: "Run the MoonCode HTTP API", RunE: func(command *cobra.Command, _ []string) error { return runServer(command.Context(), configPath) }}) + root.AddCommand(&cobra.Command{Use: "worker", Short: "Run the MoonCode Hatchet worker", RunE: func(command *cobra.Command, _ []string) error { return runWorker(command.Context(), configPath) }}) + root.AddCommand(newVersionCommand()) + root.AddCommand(&cobra.Command{Use: "migrate", Short: "Apply MoonCode database migrations", RunE: func(command *cobra.Command, _ []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return err + } + pool, err := database.Open(command.Context(), cfg.Database.URL, cfg.Database.MaxConnections) + if err != nil { + return err + } + defer pool.Close() + return database.Migrate(command.Context(), pool) + }}) + return root +} + +func runServer(parent context.Context, path string) error { + cfg, err := config.Load(path) + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) + defer stop() + application, err := build(ctx, cfg) + if err != nil { + return err + } + defer application.close() + server := &http.Server{Addr: cfg.HTTP.Address, Handler: application.server.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 2 * time.Minute} + runtimeDone := make(chan struct{}) + go func() { + defer close(runtimeDone) + _ = application.runtime.Run(ctx) + }() + errorsChannel := make(chan error, 1) + go func() { errorsChannel <- server.ListenAndServe() }() + select { + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := server.Shutdown(shutdown); err != nil { + return err + } + select { + case <-runtimeDone: + return nil + case <-shutdown.Done(): + return shutdown.Err() + } + case err := <-errorsChannel: + stop() + shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + select { + case <-runtimeDone: + case <-shutdown.Done(): + } + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} +func runWorker(parent context.Context, path string) error { + cfg, err := config.Load(path) + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) + defer stop() + application, err := build(ctx, cfg) + if err != nil { + return err } - root.AddCommand( - newServeCommand(info), - newVersionCommand(info), - newHealthcheckCommand(), - newMigrateCommand(), + defer application.close() + return runWorkerProcesses( + ctx, + application.dispatcher.Run, + func(workerContext context.Context) error { + return application.hatchet.StartWorker(workerContext, "mooncode-worker", 20) + }, + namedWorkerProcess{ + name: "worker metrics server", + run: func(metricsContext context.Context) error { + return serveWorkerMetrics(metricsContext, cfg.Metrics.WorkerAddress, application.metrics.Handler()) + }, + }, + namedWorkerProcess{ + name: "worktree janitor", + run: func(janitorContext context.Context) error { + return runWorktreeJanitor(janitorContext, application.janitor, cfg.Repository.WorktreeMaxAge) + }, + }, ) - return root } -func newMigrateCommand() *cobra.Command { - var configFile string - migrateCommand := &cobra.Command{Use: "migrate", Short: "Manage MoonCode database migrations"} - upCommand := &cobra.Command{ - Use: "up", - Short: "Apply all forward PostgreSQL migrations", - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, err := config.NewLoader().Load(configFile, nil) - if err != nil { +func runWorktreeJanitor(ctx context.Context, janitor gitrepo.WorktreeJanitor, maxAge time.Duration) error { + cleanup := func() error { + if _, err := janitor.CleanupStaleWorktrees(ctx, maxAge); err != nil { + return fmt.Errorf("clean stale analysis worktrees: %w", err) + } + + return nil + } + if err := cleanup(); err != nil { + return err + } + + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := cleanup(); err != nil { return err } - return postgres.MigrateUp(cmd.Context(), cfg.Database) - }, + } } - upCommand.Flags().StringVar(&configFile, "config", "", "path to a YAML configuration file") - migrateCommand.AddCommand(upCommand) - return migrateCommand } -func newServeCommand(info version.Info) *cobra.Command { - var ( - configFile string - listen string - adminListen string - logLevel string - ) +type workerProcess func(context.Context) error - command := &cobra.Command{ - Use: "serve", - Short: "Run the MoonCode backend", - RunE: func(cmd *cobra.Command, _ []string) error { - overrides := make(map[string]any) - if cmd.Flags().Changed("listen-address") { - overrides["server.address"] = listen - } - if cmd.Flags().Changed("admin-listen-address") { - overrides["admin.address"] = adminListen - } - if cmd.Flags().Changed("log-level") { - overrides["log.level"] = logLevel - } +type namedWorkerProcess struct { + name string + run workerProcess +} - cfg, err := config.NewLoader().Load(configFile, overrides) - if err != nil { - return err - } - app, err := bootstrap.New(cfg, bootstrap.BuildInfo{ - Version: info.Version, - Commit: info.Commit, - Date: info.Date, - }) - if err != nil { - return err - } - return app.Run(cmd.Context()) - }, +type workerProcessResult struct { + name string + err error +} + +func runWorkerProcesses(ctx context.Context, dispatcher, worker workerProcess, additional ...namedWorkerProcess) error { + processContext, cancel := context.WithCancel(ctx) + defer cancel() + + processes := append([]namedWorkerProcess{ + {name: "workflow dispatcher", run: dispatcher}, + {name: "Hatchet worker", run: worker}, + }, additional...) + results := make(chan workerProcessResult, len(processes)) + for _, process := range processes { + go func(process namedWorkerProcess) { + results <- workerProcessResult{name: process.name, err: process.run(processContext)} + }(process) } - command.Flags().StringVar(&configFile, "config", "", "path to a YAML configuration file") - command.Flags().StringVar(&listen, "listen-address", "", "application HTTP listen address") - command.Flags().StringVar(&adminListen, "admin-listen-address", "", "internal admin HTTP listen address") - command.Flags().StringVar(&logLevel, "log-level", "", "log level (trace, debug, info, warn, error)") - return command + + first := <-results + cancel() + completed := []workerProcessResult{first} + for range len(processes) - 1 { + completed = append(completed, <-results) + } + + return workerProcessError(ctx, completed) } -func newVersionCommand(info version.Info) *cobra.Command { - return &cobra.Command{ - Use: "version", - Short: "Print build version information", - RunE: func(cmd *cobra.Command, _ []string) error { - _, err := fmt.Fprintf(cmd.OutOrStdout(), "mooncode %s (commit %s, built %s)\n", info.Version, info.Commit, info.Date) - return err - }, +func workerProcessError(parent context.Context, results []workerProcessResult) error { + var processErrors []error + for index, result := range results { + if result.err == nil { + continue + } + if parent.Err() != nil && errors.Is(result.err, parent.Err()) { + continue + } + if index > 0 && errors.Is(result.err, context.Canceled) { + continue + } + + processErrors = append(processErrors, fmt.Errorf("%s: %w", result.name, result.err)) } + + return errors.Join(processErrors...) } -func newHealthcheckCommand() *cobra.Command { - var endpoint string - command := &cobra.Command{ - Use: "healthcheck", - Short: "Check the internal readiness endpoint", - RunE: func(cmd *cobra.Command, _ []string) error { - ctx, cancel := context.WithTimeout(cmd.Context(), 3*time.Second) - defer cancel() - request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return fmt.Errorf("create healthcheck request: %w", err) - } - response, err := http.DefaultClient.Do(request) - if err != nil { - return fmt.Errorf("healthcheck request: %w", err) - } - defer func() { _ = response.Body.Close() }() - if response.StatusCode != http.StatusOK { - return fmt.Errorf("backend is not ready: %s", response.Status) - } +func serveWorkerMetrics(ctx context.Context, address string, metrics http.Handler) error { + router := http.NewServeMux() + router.Handle("/metrics", metrics) + router.HandleFunc("/healthz", func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Cache-Control", "no-store") + writer.WriteHeader(http.StatusNoContent) + }) + server := &http.Server{ + Addr: address, Handler: router, ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: time.Minute, + } + errorsChannel := make(chan error, 1) + go func() { errorsChannel <- server.ListenAndServe() }() + + select { + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdown); err != nil { + return err + } + + return nil + case err := <-errorsChannel: + if errors.Is(err, http.ErrServerClosed) { return nil - }, + } + + return err } - command.Flags().StringVar(&endpoint, "url", "http://127.0.0.1:9090/readyz", "readiness URL") - return command } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 9a99353..179db96 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1,27 +1,178 @@ package cli import ( - "bytes" + "context" + "errors" + "strings" "testing" - - "github.com/mooncode-ai/mooncode/internal/version" - "github.com/stretchr/testify/require" + "time" ) -func TestVersionCommand(t *testing.T) { - output := new(bytes.Buffer) - command := NewRootCommand(version.Info{Version: "1.2.3", Commit: "abc", Date: "today"}) - command.SetOut(output) - command.SetArgs([]string{"version"}) +type worktreeJanitor struct { + calls int + maxAge time.Duration + err error +} + +func (j *worktreeJanitor) CleanupStaleWorktrees(_ context.Context, maxAge time.Duration) (int, error) { + j.calls++ + j.maxAge = maxAge + + return 0, j.err +} + +func TestWorktreeJanitorRunsAtStartupAndStopsWithContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + janitor := &worktreeJanitor{} + + if err := runWorktreeJanitor(ctx, janitor, 24*time.Hour); err != nil { + t.Fatal(err) + } + if janitor.calls != 1 || janitor.maxAge != 24*time.Hour { + t.Fatalf("janitor startup = (%d,%s)", janitor.calls, janitor.maxAge) + } +} + +func TestWorktreeJanitorReturnsCleanupFailure(t *testing.T) { + want := errors.New("worktree scan failed") + err := runWorktreeJanitor(context.Background(), &worktreeJanitor{err: want}, time.Hour) + if !errors.Is(err, want) { + t.Fatalf("runWorktreeJanitor() error = %v, want %v", err, want) + } +} + +func TestRunWorkerProcessesReturnsDispatcherFailure(t *testing.T) { + want := errors.New("outbox unavailable") + workerStopped := make(chan struct{}) + + err := runWorkerProcesses( + context.Background(), + func(context.Context) error { return want }, + func(ctx context.Context) error { + <-ctx.Done() + close(workerStopped) + + return ctx.Err() + }, + ) + + if !errors.Is(err, want) || !strings.Contains(err.Error(), "workflow dispatcher") { + t.Fatalf("runWorkerProcesses() error = %v, want dispatcher failure", err) + } + select { + case <-workerStopped: + default: + t.Fatal("Hatchet worker was not stopped") + } +} + +func TestRunWorkerProcessesReturnsWorkerFailure(t *testing.T) { + want := errors.New("worker registration failed") + dispatcherStopped := make(chan struct{}) + + err := runWorkerProcesses( + context.Background(), + func(ctx context.Context) error { + <-ctx.Done() + close(dispatcherStopped) + + return nil + }, + func(context.Context) error { return want }, + ) + + if !errors.Is(err, want) || !strings.Contains(err.Error(), "Hatchet worker") { + t.Fatalf("runWorkerProcesses() error = %v, want Hatchet worker failure", err) + } + select { + case <-dispatcherStopped: + default: + t.Fatal("workflow dispatcher was not stopped") + } +} + +func TestRunWorkerProcessesReturnsMetricsFailure(t *testing.T) { + want := errors.New("metrics listener failed") + stopped := make(chan string, 2) + wait := func(name string) workerProcess { + return func(ctx context.Context) error { + <-ctx.Done() + stopped <- name - require.NoError(t, command.Execute()) - require.Equal(t, "mooncode 1.2.3 (commit abc, built today)\n", output.String()) + return ctx.Err() + } + } + + err := runWorkerProcesses( + context.Background(), + wait("dispatcher"), + wait("worker"), + namedWorkerProcess{name: "worker metrics server", run: func(context.Context) error { return want }}, + ) + if !errors.Is(err, want) || !strings.Contains(err.Error(), "worker metrics server") { + t.Fatalf("runWorkerProcesses() error = %v, want metrics failure", err) + } + if errors.Is(err, context.Canceled) { + t.Fatalf("runWorkerProcesses() error = %v, want peer cancellations to be ignored", err) + } + for range 2 { + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("a peer process was not stopped") + } + } } -func TestServeRejectsInvalidListener(t *testing.T) { - command := NewRootCommand(version.Info{Version: "test"}) - command.SetArgs([]string{"serve", "--listen-address", "invalid"}) +func TestRunWorkerProcessesDoesNotHideUnexpectedCancellation(t *testing.T) { + err := runWorkerProcesses( + context.Background(), + func(context.Context) error { return context.Canceled }, + func(ctx context.Context) error { + <-ctx.Done() + + return ctx.Err() + }, + ) + + if !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "workflow dispatcher") { + t.Fatalf("runWorkerProcesses() error = %v, want unexpected dispatcher cancellation", err) + } +} + +func TestRunWorkerProcessesStopsGracefully(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + dispatcherStarted := make(chan struct{}) + workerStarted := make(chan struct{}) + process := func(started chan<- struct{}) workerProcess { + return func(ctx context.Context) error { + close(started) + <-ctx.Done() + + return ctx.Err() + } + } + done := make(chan error, 1) + go func() { + done <- runWorkerProcesses(ctx, process(dispatcherStarted), process(workerStarted)) + }() + + for _, started := range []<-chan struct{}{dispatcherStarted, workerStarted} { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("worker process did not start") + } + } + cancel() - err := command.Execute() - require.ErrorContains(t, err, "server.address is invalid") + select { + case err := <-done: + if err != nil { + t.Fatalf("runWorkerProcesses() error = %v, want nil", err) + } + case <-time.After(time.Second): + t.Fatal("worker processes did not stop") + } } diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..717036a --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,22 @@ +package cli + +import ( + "fmt" + + "github.com/fuchencong/mooncode/internal/platform/buildinfo" + "github.com/spf13/cobra" +) + +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print MoonCode build information", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + info := buildinfo.Current() + _, err := fmt.Fprintf(command.OutOrStdout(), "mooncode %s\ncommit: %s\nbuilt: %s\n", info.Version, info.Commit, info.BuildTime) + + return err + }, + } +} diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go new file mode 100644 index 0000000..7af110c --- /dev/null +++ b/internal/cli/version_test.go @@ -0,0 +1,31 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/fuchencong/mooncode/internal/platform/buildinfo" +) + +func TestVersionCommandReportsBuildIdentity(t *testing.T) { + previous := buildinfo.Current() + buildinfo.Version = "v1.2.3" + buildinfo.Commit = "0123456789abcdef" + buildinfo.BuildTime = "2026-07-30T10:00:00Z" + t.Cleanup(func() { + buildinfo.Version = previous.Version + buildinfo.Commit = previous.Commit + buildinfo.BuildTime = previous.BuildTime + }) + + output := new(bytes.Buffer) + command := newVersionCommand() + command.SetOut(output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + want := "mooncode v1.2.3\ncommit: 0123456789abcdef\nbuilt: 2026-07-30T10:00:00Z\n" + if output.String() != want { + t.Fatalf("version output = %q, want %q", output.String(), want) + } +} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 784f7db..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,463 +0,0 @@ -package config - -import ( - "encoding/base64" - "errors" - "fmt" - "net" - "os" - "strings" - "time" - - "github.com/spf13/viper" -) - -type Config struct { - Service ServiceConfig `mapstructure:"service"` - Database DatabaseConfig `mapstructure:"database"` - Identity IdentityConfig `mapstructure:"identity"` - Registration RegistrationConfig `mapstructure:"registration"` - SecretStore SecretStoreConfig `mapstructure:"secret_store"` - Git GitConfig `mapstructure:"git"` - Jobs JobsConfig `mapstructure:"jobs"` - Outbox OutboxConfig `mapstructure:"outbox"` - Channels ChannelsConfig `mapstructure:"channels"` - Security SecurityConfig `mapstructure:"security"` - Server HTTPServerConfig `mapstructure:"server"` - Admin HTTPServerConfig `mapstructure:"admin"` - Log LogConfig `mapstructure:"log"` - Observability ObservabilityConfig `mapstructure:"observability"` - Shutdown ShutdownConfig `mapstructure:"shutdown"` -} - -type SecurityConfig struct { - CSRF CSRFConfig `mapstructure:"csrf"` - RateLimit RateLimitConfig `mapstructure:"rate_limit"` -} -type CSRFConfig struct { - Key string `mapstructure:"key"` - Secure bool `mapstructure:"secure"` - TrustedOrigins []string `mapstructure:"trusted_origins"` -} -type RateLimitConfig struct { - PerIP LimitConfig `mapstructure:"per_ip"` - PerUser LimitConfig `mapstructure:"per_user"` - PerWorkspace LimitConfig `mapstructure:"per_workspace"` - EntryTTL time.Duration `mapstructure:"entry_ttl"` - MaxEntries int `mapstructure:"max_entries"` -} -type LimitConfig struct { - Rate float64 `mapstructure:"rate"` - Burst float64 `mapstructure:"burst"` -} - -type ChannelsConfig struct { - FakeEnabled bool `mapstructure:"fake_enabled"` - ReconcileInterval time.Duration `mapstructure:"reconcile_interval"` - LeaseDuration time.Duration `mapstructure:"lease_duration"` - DrainTimeout time.Duration `mapstructure:"drain_timeout"` - RetryBaseDelay time.Duration `mapstructure:"retry_base_delay"` - RetryMaxDelay time.Duration `mapstructure:"retry_max_delay"` - MutationCooldown time.Duration `mapstructure:"mutation_cooldown"` - MutationTimeout time.Duration `mapstructure:"mutation_timeout"` -} - -type GitConfig struct { - RepositoryDirectory string `mapstructure:"repository_directory"` - CloneTimeout time.Duration `mapstructure:"clone_timeout"` - Depth int `mapstructure:"depth"` - SyncCooldown time.Duration `mapstructure:"sync_cooldown"` - ProbeCooldown time.Duration `mapstructure:"probe_cooldown"` - AllowedPrivateCIDRs []string `mapstructure:"allowed_private_cidrs"` -} - -type JobsConfig struct { - PollInterval time.Duration `mapstructure:"poll_interval"` - LeaseDuration time.Duration `mapstructure:"lease_duration"` - RenewInterval time.Duration `mapstructure:"renew_interval"` - RetryBaseDelay time.Duration `mapstructure:"retry_base_delay"` -} - -type OutboxConfig struct { - PollInterval time.Duration `mapstructure:"poll_interval"` - BatchSize int `mapstructure:"batch_size"` - LeaseDuration time.Duration `mapstructure:"lease_duration"` - PublishTimeout time.Duration `mapstructure:"publish_timeout"` - RetryBaseDelay time.Duration `mapstructure:"retry_base_delay"` - RetryMaxDelay time.Duration `mapstructure:"retry_max_delay"` -} - -type SecretStoreConfig struct { - MasterKey string `mapstructure:"master_key"` - KeyVersion int32 `mapstructure:"key_version"` -} - -type IdentityConfig struct { - Mode string `mapstructure:"mode"` - Issuer string `mapstructure:"issuer"` - AuthURL string `mapstructure:"auth_url"` - GatewayToken string `mapstructure:"gateway_token"` -} - -type RegistrationConfig struct { - Mode string `mapstructure:"mode"` - TermsVersion string `mapstructure:"terms_version"` - PrivacyVersion string `mapstructure:"privacy_version"` - InvitationTTL time.Duration `mapstructure:"invitation_ttl"` - CreatePersonalWorkspace bool `mapstructure:"create_personal_workspace"` -} - -type DatabaseConfig struct { - URL string `mapstructure:"url"` - MaxConnections int32 `mapstructure:"max_connections"` - MinConnections int32 `mapstructure:"min_connections"` - ConnectTimeout time.Duration `mapstructure:"connect_timeout"` - HealthInterval time.Duration `mapstructure:"health_interval"` - AutoMigrate bool `mapstructure:"auto_migrate"` -} - -type ServiceConfig struct { - Environment string `mapstructure:"environment"` -} - -type HTTPServerConfig struct { - Address string `mapstructure:"address"` - ReadHeaderTimeout time.Duration `mapstructure:"read_header_timeout"` - ReadTimeout time.Duration `mapstructure:"read_timeout"` - WriteTimeout time.Duration `mapstructure:"write_timeout"` - IdleTimeout time.Duration `mapstructure:"idle_timeout"` -} - -type LogConfig struct { - Level string `mapstructure:"level"` - Format string `mapstructure:"format"` -} - -type ObservabilityConfig struct { - Tracing TracingConfig `mapstructure:"tracing"` -} - -type TracingConfig struct { - Enabled bool `mapstructure:"enabled"` - Endpoint string `mapstructure:"endpoint"` - Protocol string `mapstructure:"protocol"` - SampleRatio float64 `mapstructure:"sample_ratio"` - ExporterTimeout time.Duration `mapstructure:"exporter_timeout"` -} - -type ShutdownConfig struct { - Timeout time.Duration `mapstructure:"timeout"` -} - -type Loader struct { - v *viper.Viper -} - -var ( - developmentSecretStoreKey = developmentKey(0) - developmentCSRFKey = developmentKey(1) -) - -func NewLoader() *Loader { - v := viper.New() - v.SetEnvPrefix("MOONCODE") - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() - - v.SetDefault("service.environment", "development") - v.SetDefault("database.url", "postgres://mooncode:mooncode@127.0.0.1:5432/mooncode?sslmode=disable") - v.SetDefault("database.max_connections", 20) - v.SetDefault("database.min_connections", 2) - v.SetDefault("database.connect_timeout", 10*time.Second) - v.SetDefault("database.health_interval", 5*time.Second) - v.SetDefault("database.auto_migrate", true) - v.SetDefault("identity.mode", "oauth_subject_required") - v.SetDefault("identity.issuer", "https://github.com") - v.SetDefault("identity.auth_url", "http://auth.mooncode.localhost:3100") - v.SetDefault("identity.gateway_token", "mooncode-local-gateway-token-change-me") - v.SetDefault("registration.mode", "invite_only") - v.SetDefault("registration.terms_version", "2026-07-01") - v.SetDefault("registration.privacy_version", "2026-07-01") - v.SetDefault("registration.invitation_ttl", 7*24*time.Hour) - v.SetDefault("registration.create_personal_workspace", true) - v.SetDefault("secret_store.master_key", developmentSecretStoreKey) - v.SetDefault("secret_store.key_version", 1) - v.SetDefault("git.repository_directory", "/var/lib/mooncode/repositories") - v.SetDefault("git.clone_timeout", 10*time.Minute) - v.SetDefault("git.depth", 1) - v.SetDefault("git.sync_cooldown", 30*time.Second) - v.SetDefault("git.probe_cooldown", 15*time.Second) - v.SetDefault("git.allowed_private_cidrs", []string{}) - v.SetDefault("jobs.poll_interval", time.Second) - v.SetDefault("jobs.lease_duration", 30*time.Second) - v.SetDefault("jobs.renew_interval", 10*time.Second) - v.SetDefault("jobs.retry_base_delay", 2*time.Second) - v.SetDefault("outbox.poll_interval", time.Second) - v.SetDefault("outbox.batch_size", 100) - v.SetDefault("outbox.lease_duration", 30*time.Second) - v.SetDefault("outbox.publish_timeout", 10*time.Second) - v.SetDefault("outbox.retry_base_delay", 2*time.Second) - v.SetDefault("outbox.retry_max_delay", time.Minute) - v.SetDefault("channels.reconcile_interval", 10*time.Second) - v.SetDefault("channels.fake_enabled", false) - v.SetDefault("channels.lease_duration", 30*time.Second) - v.SetDefault("channels.drain_timeout", 5*time.Second) - v.SetDefault("channels.retry_base_delay", time.Second) - v.SetDefault("channels.retry_max_delay", time.Minute) - v.SetDefault("channels.mutation_cooldown", time.Second) - v.SetDefault("channels.mutation_timeout", 30*time.Second) - v.SetDefault("security.csrf.key", developmentCSRFKey) - v.SetDefault("security.csrf.secure", false) - v.SetDefault("security.csrf.trusted_origins", []string{"mooncode.localhost:3100"}) - v.SetDefault("security.rate_limit.per_ip.rate", 30.0) - v.SetDefault("security.rate_limit.per_ip.burst", 60.0) - v.SetDefault("security.rate_limit.per_user.rate", 20.0) - v.SetDefault("security.rate_limit.per_user.burst", 40.0) - v.SetDefault("security.rate_limit.per_workspace.rate", 50.0) - v.SetDefault("security.rate_limit.per_workspace.burst", 100.0) - v.SetDefault("security.rate_limit.entry_ttl", 10*time.Minute) - v.SetDefault("security.rate_limit.max_entries", 10000) - v.SetDefault("server.address", ":8080") - v.SetDefault("server.read_header_timeout", 5*time.Second) - v.SetDefault("server.read_timeout", 15*time.Second) - v.SetDefault("server.write_timeout", 30*time.Second) - v.SetDefault("server.idle_timeout", 60*time.Second) - // Bind operational endpoints to loopback by default. A wildcard bind would - // expose the admin listener on every Docker network joined by the single - // backend container, including the edge network. - v.SetDefault("admin.address", "127.0.0.1:9090") - v.SetDefault("admin.read_header_timeout", 3*time.Second) - v.SetDefault("admin.read_timeout", 5*time.Second) - v.SetDefault("admin.write_timeout", 10*time.Second) - v.SetDefault("admin.idle_timeout", 30*time.Second) - v.SetDefault("log.level", "info") - v.SetDefault("log.format", "json") - v.SetDefault("observability.tracing.enabled", false) - v.SetDefault("observability.tracing.protocol", "http/protobuf") - v.SetDefault("observability.tracing.sample_ratio", 1.0) - v.SetDefault("observability.tracing.exporter_timeout", 5*time.Second) - v.SetDefault("shutdown.timeout", 15*time.Second) - - return &Loader{v: v} -} - -func (l *Loader) Load(configFile string, overrides map[string]any) (Config, error) { - if configFile != "" { - l.v.SetConfigFile(configFile) - if err := l.v.ReadInConfig(); err != nil { - return Config{}, fmt.Errorf("read config: %w", err) - } - } else { - l.v.SetConfigName("mooncode") - l.v.SetConfigType("yaml") - l.v.AddConfigPath(".") - if err := l.v.ReadInConfig(); err != nil { - var notFound viper.ConfigFileNotFoundError - if !errors.As(err, ¬Found) { - return Config{}, fmt.Errorf("read config: %w", err) - } - } - } - - for key, value := range overrides { - l.v.Set(key, value) - } - - var cfg Config - if err := l.v.UnmarshalExact(&cfg); err != nil { - return Config{}, fmt.Errorf("decode config: %w", err) - } - if err := resolveSecretFiles(&cfg); err != nil { - return Config{}, err - } - if err := cfg.Validate(); err != nil { - return Config{}, err - } - return cfg, nil -} - -func resolveSecretFiles(cfg *Config) error { - values := []struct { - environment string - set func(string) - }{ - {"MOONCODE_DATABASE_URL", func(value string) { cfg.Database.URL = value }}, - {"MOONCODE_IDENTITY_GATEWAY_TOKEN", func(value string) { cfg.Identity.GatewayToken = value }}, - {"MOONCODE_SECRET_STORE_MASTER_KEY", func(value string) { cfg.SecretStore.MasterKey = value }}, - {"MOONCODE_SECURITY_CSRF_KEY", func(value string) { cfg.Security.CSRF.Key = value }}, - } - for _, value := range values { - path, configured := os.LookupEnv(value.environment + "_FILE") - if !configured || strings.TrimSpace(path) == "" { - continue - } - if _, direct := os.LookupEnv(value.environment); direct { - return fmt.Errorf("%s and %s_FILE cannot both be set", value.environment, value.environment) - } - contents, err := os.ReadFile(strings.TrimSpace(path)) - if err != nil { - return fmt.Errorf("read %s_FILE: %w", value.environment, err) - } - resolved := strings.TrimSuffix(strings.TrimSuffix(string(contents), "\n"), "\r") - if resolved == "" { - return fmt.Errorf("%s_FILE is empty", value.environment) - } - value.set(resolved) - } - return nil -} - -func (c Config) Validate() error { - var errs []error - if err := validateAddress("server.address", c.Server.Address); err != nil { - errs = append(errs, err) - } - if err := validateAddress("admin.address", c.Admin.Address); err != nil { - errs = append(errs, err) - } - if sameNonEphemeralAddress(c.Server.Address, c.Admin.Address) { - errs = append(errs, errors.New("server.address and admin.address must differ")) - } - if strings.TrimSpace(c.Service.Environment) == "" { - errs = append(errs, errors.New("service.environment is required")) - } - if strings.TrimSpace(c.Database.URL) == "" { - errs = append(errs, errors.New("database.url is required")) - } - if c.Database.MaxConnections <= 0 { - errs = append(errs, errors.New("database.max_connections must be positive")) - } - if c.Database.MinConnections < 0 || c.Database.MinConnections > c.Database.MaxConnections { - errs = append(errs, errors.New("database.min_connections must be between zero and database.max_connections")) - } - if c.Database.ConnectTimeout <= 0 { - errs = append(errs, errors.New("database.connect_timeout must be positive")) - } - if c.Database.HealthInterval <= 0 { - errs = append(errs, errors.New("database.health_interval must be positive")) - } - if strings.TrimSpace(c.Identity.Issuer) == "" { - errs = append(errs, errors.New("identity.issuer is required")) - } - if c.Identity.Mode != "oauth_subject_required" { - errs = append(errs, errors.New("identity.mode must be oauth_subject_required")) - } - if strings.TrimSpace(c.Identity.AuthURL) == "" { - errs = append(errs, errors.New("identity.auth_url is required")) - } - if len(c.Identity.GatewayToken) < 24 { - errs = append(errs, errors.New("identity.gateway_token must contain at least 24 characters")) - } - if c.Registration.Mode != "disabled" && c.Registration.Mode != "invite_only" && c.Registration.Mode != "public" { - errs = append(errs, errors.New("registration.mode must be disabled, invite_only, or public")) - } - if strings.TrimSpace(c.Registration.TermsVersion) == "" || strings.TrimSpace(c.Registration.PrivacyVersion) == "" { - errs = append(errs, errors.New("registration terms_version and privacy_version are required")) - } - if c.Registration.InvitationTTL <= 0 { - errs = append(errs, errors.New("registration.invitation_ttl must be positive")) - } - if strings.TrimSpace(c.SecretStore.MasterKey) == "" { - errs = append(errs, errors.New("secret_store.master_key is required")) - } else if c.Service.Environment != "development" && c.SecretStore.MasterKey == developmentSecretStoreKey { - errs = append(errs, errors.New("secret_store.master_key must be configured outside development")) - } - if c.SecretStore.KeyVersion <= 0 { - errs = append(errs, errors.New("secret_store.key_version must be positive")) - } - if strings.TrimSpace(c.Git.RepositoryDirectory) == "" || c.Git.CloneTimeout <= 0 || c.Git.Depth <= 0 || c.Git.Depth > 1000 || c.Git.SyncCooldown < 0 || c.Git.ProbeCooldown < 0 { - errs = append(errs, errors.New("git repository_directory, clone_timeout, depth, sync_cooldown, and probe_cooldown are invalid")) - } - for _, cidr := range c.Git.AllowedPrivateCIDRs { - if _, _, err := net.ParseCIDR(cidr); err != nil { - errs = append(errs, fmt.Errorf("git.allowed_private_cidrs contains invalid CIDR %q", cidr)) - } - } - if c.Jobs.PollInterval <= 0 || c.Jobs.LeaseDuration <= 0 || c.Jobs.RenewInterval <= 0 || c.Jobs.RetryBaseDelay <= 0 || c.Jobs.RenewInterval >= c.Jobs.LeaseDuration { - errs = append(errs, errors.New("jobs timing configuration is invalid")) - } - if c.Outbox.PollInterval <= 0 || c.Outbox.BatchSize <= 0 || c.Outbox.BatchSize > 1000 || - c.Outbox.LeaseDuration <= 0 || c.Outbox.PublishTimeout <= 0 || c.Outbox.PublishTimeout >= c.Outbox.LeaseDuration || - c.Outbox.RetryBaseDelay <= 0 || c.Outbox.RetryMaxDelay < c.Outbox.RetryBaseDelay { - errs = append(errs, errors.New("outbox configuration is invalid")) - } - if c.Channels.ReconcileInterval <= 0 || c.Channels.LeaseDuration <= c.Channels.ReconcileInterval*2 || c.Channels.DrainTimeout <= 0 || - c.Channels.RetryBaseDelay <= 0 || c.Channels.RetryMaxDelay < c.Channels.RetryBaseDelay || - c.Channels.MutationCooldown < 0 || c.Channels.MutationTimeout <= 0 { - errs = append(errs, errors.New("channels timing configuration is invalid")) - } - csrfKey, err := base64.StdEncoding.DecodeString(c.Security.CSRF.Key) - if err != nil || len(csrfKey) != 32 { - errs = append(errs, errors.New("security.csrf.key must be a base64-encoded 32-byte key")) - } else if c.Service.Environment != "development" && c.Security.CSRF.Key == developmentCSRFKey { - errs = append(errs, errors.New("security.csrf.key must be configured outside development")) - } - for _, limit := range []LimitConfig{c.Security.RateLimit.PerIP, c.Security.RateLimit.PerUser, c.Security.RateLimit.PerWorkspace} { - if limit.Rate <= 0 || limit.Burst < 1 { - errs = append(errs, errors.New("security rate limits must have positive rate and burst")) - break - } - } - if c.Security.RateLimit.EntryTTL <= 0 || c.Security.RateLimit.MaxEntries <= 0 { - errs = append(errs, errors.New("security rate limit entry_ttl and max_entries must be positive")) - } - if _, err := parseLogLevel(c.Log.Level); err != nil { - errs = append(errs, err) - } - if c.Log.Format != "json" && c.Log.Format != "console" { - errs = append(errs, errors.New("log.format must be json or console")) - } - if c.Shutdown.Timeout <= 0 { - errs = append(errs, errors.New("shutdown.timeout must be positive")) - } - if c.Observability.Tracing.SampleRatio < 0 || c.Observability.Tracing.SampleRatio > 1 { - errs = append(errs, errors.New("observability.tracing.sample_ratio must be between 0 and 1")) - } - if c.Observability.Tracing.Enabled { - if strings.TrimSpace(c.Observability.Tracing.Endpoint) == "" { - errs = append(errs, errors.New("observability.tracing.endpoint is required when tracing is enabled")) - } - if c.Observability.Tracing.Protocol != "http/protobuf" { - errs = append(errs, errors.New("observability.tracing.protocol must be http/protobuf")) - } - if c.Observability.Tracing.ExporterTimeout <= 0 { - errs = append(errs, errors.New("observability.tracing.exporter_timeout must be positive")) - } - } - return errors.Join(errs...) -} - -func developmentKey(fill byte) string { - key := make([]byte, 32) - for index := range key { - key[index] = fill - } - return base64.StdEncoding.EncodeToString(key) -} - -func sameNonEphemeralAddress(first, second string) bool { - if first != second { - return false - } - _, port, err := net.SplitHostPort(first) - return err != nil || port != "0" -} - -func validateAddress(name, address string) error { - if strings.TrimSpace(address) == "" { - return fmt.Errorf("%s is required", name) - } - if _, _, err := net.SplitHostPort(address); err != nil { - return fmt.Errorf("%s is invalid: %w", name, err) - } - return nil -} - -func parseLogLevel(level string) (string, error) { - switch strings.ToLower(strings.TrimSpace(level)) { - case "trace", "debug", "info", "warn", "error": - return strings.ToLower(strings.TrimSpace(level)), nil - default: - return "", fmt.Errorf("log.level %q is invalid", level) - } -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index 3b2e9d1..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package config - -import ( - "encoding/base64" - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestLoaderDefaults(t *testing.T) { - cfg, err := NewLoader().Load("", nil) - require.NoError(t, err) - require.Equal(t, ":8080", cfg.Server.Address) - require.Equal(t, "127.0.0.1:9090", cfg.Admin.Address) - require.Equal(t, 15*time.Second, cfg.Shutdown.Timeout) - require.False(t, cfg.Observability.Tracing.Enabled) - require.False(t, cfg.Channels.FakeEnabled) - require.Equal(t, "oauth_subject_required", cfg.Identity.Mode) - require.Equal(t, "https://github.com", cfg.Identity.Issuer) - require.Equal(t, "invite_only", cfg.Registration.Mode) - csrfKey, decodeErr := base64.StdEncoding.DecodeString(cfg.Security.CSRF.Key) - require.NoError(t, decodeErr) - require.Len(t, csrfKey, 32) -} - -func TestLoaderResolvesSecretFileEnvironment(t *testing.T) { - path := filepath.Join(t.TempDir(), "gateway-token") - require.NoError(t, os.WriteFile(path, []byte("a-file-backed-gateway-token-value\n"), 0o600)) - t.Setenv("MOONCODE_IDENTITY_GATEWAY_TOKEN_FILE", path) - - cfg, err := NewLoader().Load("", nil) - require.NoError(t, err) - require.Equal(t, "a-file-backed-gateway-token-value", cfg.Identity.GatewayToken) -} - -func TestLoaderRejectsDirectAndFileSecretTogether(t *testing.T) { - path := filepath.Join(t.TempDir(), "gateway-token") - require.NoError(t, os.WriteFile(path, []byte("a-file-backed-gateway-token-value"), 0o600)) - t.Setenv("MOONCODE_IDENTITY_GATEWAY_TOKEN", "a-direct-gateway-token-value") - t.Setenv("MOONCODE_IDENTITY_GATEWAY_TOKEN_FILE", path) - - _, err := NewLoader().Load("", nil) - require.ErrorContains(t, err, "cannot both be set") -} - -func TestConfigRejectsInvalidCSRFKey(t *testing.T) { - _, err := NewLoader().Load("", map[string]any{"security.csrf.key": "not-base64"}) - require.ErrorContains(t, err, "base64-encoded 32-byte key") -} - -func TestConfigRejectsDevelopmentKeysOutsideDevelopment(t *testing.T) { - _, err := NewLoader().Load("", map[string]any{"service.environment": "production"}) - require.ErrorContains(t, err, "secret_store.master_key must be configured outside development") - require.ErrorContains(t, err, "security.csrf.key must be configured outside development") -} - -func TestLoaderOverrides(t *testing.T) { - cfg, err := NewLoader().Load("", map[string]any{ - "server.address": ":18080", - "log.level": "debug", - }) - require.NoError(t, err) - require.Equal(t, ":18080", cfg.Server.Address) - require.Equal(t, "debug", cfg.Log.Level) -} - -func TestConfigRejectsSameListeners(t *testing.T) { - _, err := NewLoader().Load("", map[string]any{ - "server.address": ":8080", - "admin.address": ":8080", - }) - require.ErrorContains(t, err, "must differ") -} - -func TestConfigRejectsLegacyIdentityAndRegistrationModes(t *testing.T) { - _, err := NewLoader().Load("", map[string]any{"identity.mode": "username_fallback"}) - require.ErrorContains(t, err, "oauth_subject_required") - - _, err = NewLoader().Load("", map[string]any{"registration.mode": "automatic"}) - require.ErrorContains(t, err, "disabled, invite_only, or public") -} diff --git a/internal/controller/audit.go b/internal/controller/audit.go deleted file mode 100644 index 92c14e5..0000000 --- a/internal/controller/audit.go +++ /dev/null @@ -1,38 +0,0 @@ -package controller - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/mooncode-ai/mooncode/internal/service" -) - -type AuditController struct{ audits *service.AuditService } - -func NewAuditController(audits *service.AuditService) *AuditController { - return &AuditController{audits: audits} -} - -func (controller *AuditController) Register(api *gin.RouterGroup) { - api.GET("/workspaces/:workspaceID/audit-logs", controller.list) -} - -func (controller *AuditController) list(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - pageSize := int32(50) - if raw := c.Query("pageSize"); raw != "" { - if parsed, err := strconv.ParseInt(raw, 10, 32); err == nil { - pageSize = int32(parsed) - } - } - page, err := controller.audits.List(c.Request.Context(), principal, workspaceID, c.Query("cursor"), pageSize) - if err != nil { - writeServiceError(c, err, "audit.list_failed") - return - } - c.JSON(http.StatusOK, page) -} diff --git a/internal/controller/channel.go b/internal/controller/channel.go deleted file mode 100644 index c20d2df..0000000 --- a/internal/controller/channel.go +++ /dev/null @@ -1,213 +0,0 @@ -package controller - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/service" - channelcore "github.com/mooncode-ai/mooncode/pkg/channel" -) - -type ChannelController struct { - channels *service.ChannelService - messages *service.MessageService -} - -func NewChannelController(channels *service.ChannelService, messages *service.MessageService) *ChannelController { - return &ChannelController{channels: channels, messages: messages} -} -func (controller *ChannelController) Register(api *gin.RouterGroup) { - api.GET("/channel-types", controller.types) - workspaces := api.Group("/workspaces/:workspaceID") - workspaces.GET("/channels", controller.list) - workspaces.POST("/channels", controller.create) - workspaces.PATCH("/channels/:channelID", controller.update) - workspaces.DELETE("/channels/:channelID", controller.delete) - workspaces.POST("/channels/:channelID/enable", controller.enable) - workspaces.POST("/channels/:channelID/disable", controller.disable) - workspaces.GET("/channels/:channelID/status", controller.status) - workspaces.GET("/messages", controller.listMessages) - workspaces.GET("/conversations", controller.listConversations) -} - -func (controller *ChannelController) delete(c *gin.Context) { - principal, workspaceID, channelID, ok := channelScope(c) - if !ok { - return - } - if err := controller.channels.Delete(c.Request.Context(), principal, workspaceID, channelID, c.GetHeader("Idempotency-Key")); err != nil { - writeServiceError(c, err, "channel.delete_failed") - return - } - c.Status(http.StatusNoContent) -} -func (controller *ChannelController) types(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"items": controller.channels.Descriptors()}) -} - -type createChannelRequest struct { - Type string `json:"type"` - Name string `json:"name"` - Values map[string]any `json:"values"` - Secrets map[string]string `json:"secrets"` - SenderAllowList []string `json:"senderAllowList"` - GroupPolicy channelcore.GroupPolicy `json:"groupPolicy"` -} - -func (controller *ChannelController) create(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - var request createChannelRequest - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - value, err := controller.channels.Create(c.Request.Context(), principal, workspaceID, service.CreateChannelInput{Type: request.Type, Name: request.Name, Values: request.Values, Secrets: request.Secrets, SenderAllowList: request.SenderAllowList, GroupPolicy: request.GroupPolicy, IdempotencyKey: c.GetHeader("Idempotency-Key")}) - if err != nil { - writeServiceError(c, err, "channel.create_failed") - return - } - c.JSON(http.StatusCreated, value) -} -func (controller *ChannelController) list(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - values, err := controller.channels.List(c.Request.Context(), principal, workspaceID) - if err != nil { - writeServiceError(c, err, "channel.list_failed") - return - } - items, nextCursor, err := paginate(values, c.Query("cursor"), c.Query("pageSize"), func(value model.ChannelInstance) pageKey { return pageKey{time: value.CreatedAt, id: value.ID} }) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters") - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor}) -} - -type updateChannelRequest struct { - Version int64 `json:"version"` - Name *string `json:"name"` - Values map[string]any `json:"values"` - Secrets map[string]string `json:"secrets"` - ClearSecretFields []string `json:"clearSecretFields"` - SenderAllowList *[]string `json:"senderAllowList"` - GroupPolicy *channelcore.GroupPolicy `json:"groupPolicy"` -} - -func (controller *ChannelController) update(c *gin.Context) { - principal, workspaceID, channelID, ok := channelScope(c) - if !ok { - return - } - var request updateChannelRequest - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - value, err := controller.channels.Update(c.Request.Context(), principal, workspaceID, channelID, service.UpdateChannelInput{Version: request.Version, Name: request.Name, Values: request.Values, Secrets: request.Secrets, ClearSecretFields: request.ClearSecretFields, SenderAllowList: request.SenderAllowList, GroupPolicy: request.GroupPolicy, IdempotencyKey: c.GetHeader("Idempotency-Key")}) - if err != nil { - writeServiceError(c, err, "channel.update_failed") - return - } - c.JSON(http.StatusOK, value) -} -func (controller *ChannelController) enable(c *gin.Context) { controller.setEnabled(c, true) } -func (controller *ChannelController) disable(c *gin.Context) { controller.setEnabled(c, false) } -func (controller *ChannelController) setEnabled(c *gin.Context, enabled bool) { - principal, workspaceID, channelID, ok := channelScope(c) - if !ok { - return - } - value, err := controller.channels.SetEnabled(c.Request.Context(), principal, workspaceID, channelID, enabled, c.GetHeader("Idempotency-Key")) - if err != nil { - writeServiceError(c, err, "channel.state_failed") - return - } - c.JSON(http.StatusOK, value) -} -func (controller *ChannelController) status(c *gin.Context) { - principal, workspaceID, channelID, ok := channelScope(c) - if !ok { - return - } - value, err := controller.channels.Status(c.Request.Context(), principal, workspaceID, channelID) - if err != nil { - writeServiceError(c, err, "channel.status_failed") - return - } - c.JSON(http.StatusOK, value) -} -func channelScope(c *gin.Context) (model.Principal, uuid.UUID, uuid.UUID, bool) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return model.Principal{}, uuid.Nil, uuid.Nil, false - } - id, err := uuid.Parse(c.Param("channelID")) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "channel.invalid_id", "Invalid channel ID") - return model.Principal{}, uuid.Nil, uuid.Nil, false - } - return principal, workspaceID, id, true -} - -func (controller *ChannelController) listMessages(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - var channelID *uuid.UUID - if raw := c.Query("channelId"); raw != "" { - parsed, err := uuid.Parse(raw) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "channel.invalid_id", "Invalid channel ID") - return - } - channelID = &parsed - } - var conversationID *uuid.UUID - if raw := c.Query("conversationId"); raw != "" { - parsed, err := uuid.Parse(raw) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "conversation.invalid_id", "Invalid conversation ID") - return - } - conversationID = &parsed - } - pageSize := int32(50) - if raw := c.Query("pageSize"); raw != "" { - if parsed, err := strconv.ParseInt(raw, 10, 32); err == nil { - pageSize = int32(parsed) - } - } - page, err := controller.messages.List(c.Request.Context(), principal, workspaceID, channelID, conversationID, c.Query("cursor"), pageSize) - if err != nil { - writeServiceError(c, err, "message.list_failed") - return - } - c.JSON(http.StatusOK, page) -} -func (controller *ChannelController) listConversations(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - values, err := controller.messages.Conversations(c.Request.Context(), principal, workspaceID) - if err != nil { - writeServiceError(c, err, "conversation.list_failed") - return - } - items, nextCursor, err := paginate(values, c.Query("cursor"), c.Query("pageSize"), func(value model.IMConversation) pageKey { return pageKey{time: value.UpdatedAt, id: value.ID} }) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters") - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor}) -} diff --git a/internal/controller/health.go b/internal/controller/health.go deleted file mode 100644 index 90e701f..0000000 --- a/internal/controller/health.go +++ /dev/null @@ -1,39 +0,0 @@ -package controller - -import ( - "encoding/json" - "net/http" - - "github.com/mooncode-ai/mooncode/internal/health" -) - -type HealthController struct { - state *health.State -} - -func NewHealthController(state *health.State) *HealthController { - return &HealthController{state: state} -} - -func (controller *HealthController) Register(mux *http.ServeMux) { - mux.HandleFunc("GET /livez", controller.live) - mux.HandleFunc("GET /readyz", controller.ready) -} - -func (controller *HealthController) live(writer http.ResponseWriter, _ *http.Request) { - writeHealth(writer, http.StatusOK, "live") -} - -func (controller *HealthController) ready(writer http.ResponseWriter, _ *http.Request) { - if !controller.state.Ready() { - writeHealth(writer, http.StatusServiceUnavailable, "not_ready") - return - } - writeHealth(writer, http.StatusOK, "ready") -} - -func writeHealth(writer http.ResponseWriter, status int, state string) { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(status) - _ = json.NewEncoder(writer).Encode(map[string]string{"status": state}) -} diff --git a/internal/controller/health_test.go b/internal/controller/health_test.go deleted file mode 100644 index 9997ab5..0000000 --- a/internal/controller/health_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package controller - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/mooncode-ai/mooncode/internal/health" - "github.com/stretchr/testify/require" -) - -func TestHealthControllerReadiness(t *testing.T) { - state := health.NewState() - mux := http.NewServeMux() - NewHealthController(state).Register(mux) - - response := httptest.NewRecorder() - mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil)) - require.Equal(t, http.StatusServiceUnavailable, response.Code) - - state.SetReady(true) - response = httptest.NewRecorder() - mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil)) - require.Equal(t, http.StatusOK, response.Code) - - state.StartShutdown() - response = httptest.NewRecorder() - mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil)) - require.Equal(t, http.StatusServiceUnavailable, response.Code) -} diff --git a/internal/controller/identity.go b/internal/controller/identity.go deleted file mode 100644 index 8ab3d04..0000000 --- a/internal/controller/identity.go +++ /dev/null @@ -1,92 +0,0 @@ -package controller - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - identityctx "github.com/mooncode-ai/mooncode/internal/identity" - "github.com/mooncode-ai/mooncode/internal/middleware" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/service" -) - -type IdentityController struct { - identities *service.IdentityService -} - -func NewIdentityController(identities *service.IdentityService) *IdentityController { - return &IdentityController{identities: identities} -} - -func (controller *IdentityController) Register(api *gin.RouterGroup) { - api.GET("/session", controller.session) - api.GET("/workspaces", controller.workspaces) - api.GET("/workspaces/:workspaceID", controller.workspace) -} - -func (controller *IdentityController) session(c *gin.Context) { - principal, ok := identityctx.PrincipalFromContext(c.Request.Context()) - if !ok { - writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required") - return - } - session, err := controller.identities.Session(c.Request.Context(), principal) - if err != nil { - writeAPIError(c, http.StatusInternalServerError, "session.failed", "Unable to load session") - return - } - c.JSON(http.StatusOK, session) -} - -func (controller *IdentityController) workspaces(c *gin.Context) { - principal, ok := identityctx.PrincipalFromContext(c.Request.Context()) - if !ok { - writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required") - return - } - session, err := controller.identities.Session(c.Request.Context(), principal) - if err != nil { - writeAPIError(c, http.StatusInternalServerError, "workspace.list_failed", "Unable to list workspaces") - return - } - items, nextCursor, err := paginate(session.Workspaces, c.Query("cursor"), c.Query("pageSize"), func(value model.Workspace) pageKey { - return pageKey{time: value.CreatedAt, id: value.ID} - }) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters") - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor}) -} - -func (controller *IdentityController) workspace(c *gin.Context) { - principal, ok := identityctx.PrincipalFromContext(c.Request.Context()) - if !ok { - writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required") - return - } - workspaceID, err := uuid.Parse(c.Param("workspaceID")) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "workspace.invalid_id", "Invalid workspace ID") - return - } - workspace, err := controller.identities.Workspace(c.Request.Context(), principal, workspaceID) - if errors.Is(err, repository.ErrNotFound) { - writeAPIError(c, http.StatusNotFound, "workspace.not_found", "Workspace not found") - return - } - if err != nil { - writeAPIError(c, http.StatusInternalServerError, "workspace.failed", "Unable to load workspace") - return - } - c.JSON(http.StatusOK, workspace) -} - -func writeAPIError(c *gin.Context, status int, code, message string) { - c.JSON(status, gin.H{"error": gin.H{ - "code": code, "message": message, "requestId": middleware.RequestIDFromContext(c.Request.Context()), - }}) -} diff --git a/internal/controller/overview.go b/internal/controller/overview.go deleted file mode 100644 index bed5577..0000000 --- a/internal/controller/overview.go +++ /dev/null @@ -1,31 +0,0 @@ -package controller - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/mooncode-ai/mooncode/internal/service" -) - -type OverviewController struct{ overview *service.OverviewService } - -func NewOverviewController(overview *service.OverviewService) *OverviewController { - return &OverviewController{overview: overview} -} - -func (controller *OverviewController) Register(api *gin.RouterGroup) { - api.GET("/workspaces/:workspaceID/overview", controller.get) -} - -func (controller *OverviewController) get(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - value, err := controller.overview.Get(c.Request.Context(), principal, workspaceID) - if err != nil { - writeServiceError(c, err, "overview.get_failed") - return - } - c.JSON(http.StatusOK, value) -} diff --git a/internal/controller/pagination.go b/internal/controller/pagination.go deleted file mode 100644 index 1ce31fc..0000000 --- a/internal/controller/pagination.go +++ /dev/null @@ -1,92 +0,0 @@ -package controller - -import ( - "bytes" - "encoding/base64" - "errors" - "sort" - "strconv" - "strings" - "time" - - "github.com/google/uuid" -) - -type pageKey struct { - time time.Time - id uuid.UUID -} - -func paginate[T any](values []T, rawCursor, rawPageSize string, key func(T) pageKey) ([]T, string, error) { - pageSize := 50 - if rawPageSize != "" { - parsed, err := strconv.Atoi(rawPageSize) - if err != nil || parsed < 1 || parsed > 100 { - return nil, "", errors.New("page size must be between 1 and 100") - } - pageSize = parsed - } - cursor, err := decodePageKey(rawCursor) - if err != nil { - return nil, "", err - } - ordered := append([]T(nil), values...) - sort.SliceStable(ordered, func(left, right int) bool { - leftKey, rightKey := key(ordered[left]), key(ordered[right]) - if !leftKey.time.Equal(rightKey.time) { - return leftKey.time.After(rightKey.time) - } - return bytes.Compare(leftKey.id[:], rightKey.id[:]) > 0 - }) - filtered := make([]T, 0, min(len(ordered), pageSize+1)) - for _, value := range ordered { - candidate := key(value) - if rawCursor != "" && !pageKeyBefore(candidate, cursor) { - continue - } - filtered = append(filtered, value) - if len(filtered) == pageSize+1 { - break - } - } - if len(filtered) <= pageSize { - return filtered, "", nil - } - items := filtered[:pageSize] - return items, encodePageKey(key(items[len(items)-1])), nil -} - -func pageKeyBefore(left, right pageKey) bool { - if !left.time.Equal(right.time) { - return left.time.Before(right.time) - } - return bytes.Compare(left.id[:], right.id[:]) < 0 -} - -func encodePageKey(value pageKey) string { - plain := value.time.UTC().Format(time.RFC3339Nano) + "|" + value.id.String() - return base64.RawURLEncoding.EncodeToString([]byte(plain)) -} - -func decodePageKey(value string) (pageKey, error) { - if value == "" { - return pageKey{}, nil - } - decoded, err := base64.RawURLEncoding.DecodeString(value) - if err != nil { - return pageKey{}, errors.New("invalid cursor") - } - parts := strings.Split(string(decoded), "|") - if len(parts) != 2 { - return pageKey{}, errors.New("invalid cursor") - } - timestamp, err := time.Parse(time.RFC3339Nano, parts[0]) - if err != nil { - return pageKey{}, errors.New("invalid cursor") - } - id, err := uuid.Parse(parts[1]) - if err != nil { - return pageKey{}, errors.New("invalid cursor") - } - return pageKey{time: timestamp, id: id}, nil -} diff --git a/internal/controller/pagination_test.go b/internal/controller/pagination_test.go deleted file mode 100644 index d3e3047..0000000 --- a/internal/controller/pagination_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package controller - -import ( - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func TestPaginateUsesStableTimeAndIDCursor(t *testing.T) { - now := time.Now().UTC() - type item struct { - id uuid.UUID - at time.Time - } - values := []item{ - {id: uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd"), at: now.Add(-time.Second)}, - {id: uuid.MustParse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), at: now}, - {id: uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff"), at: now}, - } - key := func(value item) pageKey { return pageKey{time: value.at, id: value.id} } - - first, cursor, err := paginate(values, "", "2", key) - require.NoError(t, err) - require.Len(t, first, 2) - require.Equal(t, uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff"), first[0].id) - require.Equal(t, uuid.MustParse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), first[1].id) - require.NotEmpty(t, cursor) - - second, next, err := paginate(values, cursor, "2", key) - require.NoError(t, err) - require.Len(t, second, 1) - require.Equal(t, uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd"), second[0].id) - require.Empty(t, next) -} - -func TestPaginateRejectsInvalidInputs(t *testing.T) { - _, _, err := paginate([]int{}, "invalid", "50", func(int) pageKey { return pageKey{} }) - require.Error(t, err) - _, _, err = paginate([]int{}, "", "101", func(int) pageKey { return pageKey{} }) - require.Error(t, err) -} diff --git a/internal/controller/registration.go b/internal/controller/registration.go deleted file mode 100644 index e34ee01..0000000 --- a/internal/controller/registration.go +++ /dev/null @@ -1,196 +0,0 @@ -package controller - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - identityctx "github.com/mooncode-ai/mooncode/internal/identity" - "github.com/mooncode-ai/mooncode/internal/middleware" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/service" -) - -type RegistrationController struct { - registrations *service.RegistrationService - identities *service.IdentityService -} - -func NewRegistrationController(registrations *service.RegistrationService, identities *service.IdentityService) *RegistrationController { - return &RegistrationController{registrations: registrations, identities: identities} -} - -func (controller *RegistrationController) Register(api *gin.RouterGroup) { - api.GET("/onboarding", controller.onboarding) - api.POST("/onboarding/complete", controller.complete) - api.POST("/invitations/accept", controller.accept) - api.GET("/workspaces/:workspaceID/invitations", controller.list) - api.POST("/workspaces/:workspaceID/invitations", controller.create) - api.DELETE("/workspaces/:workspaceID/invitations/:invitationID", controller.revoke) -} - -func (controller *RegistrationController) onboarding(c *gin.Context) { - principal, ok := registrationPrincipal(c) - if !ok { - return - } - session, err := controller.identities.Session(c.Request.Context(), principal) - if err != nil { - writeAPIError(c, http.StatusInternalServerError, "onboarding.failed", "Unable to load registration") - return - } - c.Header("Cache-Control", "no-store") - c.JSON(http.StatusOK, session) -} - -type agreementRequest struct { - AcceptTerms bool `json:"acceptTerms"` - AcceptPrivacy bool `json:"acceptPrivacy"` -} - -func (controller *RegistrationController) complete(c *gin.Context) { - principal, ok := registrationPrincipal(c) - if !ok { - return - } - var request agreementRequest - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - session, err := controller.registrations.Complete(c.Request.Context(), principal, service.CompleteRegistrationInput{ - AcceptTerms: request.AcceptTerms, AcceptPrivacy: request.AcceptPrivacy, - RequestID: middleware.RequestIDFromContext(c.Request.Context()), - }) - if err != nil { - writeRegistrationError(c, err, "onboarding.failed") - return - } - c.JSON(http.StatusOK, session) -} - -type invitationRequest struct { - Email string `json:"email"` - Role string `json:"role"` -} - -func (controller *RegistrationController) create(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - var request invitationRequest - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - invitation, err := controller.registrations.CreateInvitation(c.Request.Context(), principal, workspaceID, service.CreateInvitationInput{ - Email: request.Email, Role: request.Role, - }) - if err != nil { - writeRegistrationError(c, err, "invitation.create_failed") - return - } - c.JSON(http.StatusCreated, invitation) -} - -func (controller *RegistrationController) list(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - invitations, err := controller.registrations.ListInvitations(c.Request.Context(), principal, workspaceID) - if err != nil { - writeRegistrationError(c, err, "invitation.list_failed") - return - } - items, nextCursor, err := paginate(invitations, c.Query("cursor"), c.Query("pageSize"), func(value model.WorkspaceInvitation) pageKey { - return pageKey{time: value.CreatedAt, id: value.ID} - }) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters") - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor}) -} - -func (controller *RegistrationController) revoke(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - invitationID, err := uuid.Parse(c.Param("invitationID")) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "invitation.invalid_id", "Invalid invitation ID") - return - } - if err := controller.registrations.RevokeInvitation(c.Request.Context(), principal, workspaceID, invitationID); err != nil { - writeRegistrationError(c, err, "invitation.revoke_failed") - return - } - c.Status(http.StatusNoContent) -} - -type acceptInvitationRequest struct { - Token string `json:"token"` - AcceptTerms bool `json:"acceptTerms"` - AcceptPrivacy bool `json:"acceptPrivacy"` -} - -func (controller *RegistrationController) accept(c *gin.Context) { - principal, ok := registrationPrincipal(c) - if !ok { - return - } - var request acceptInvitationRequest - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - session, err := controller.registrations.AcceptInvitation(c.Request.Context(), principal, service.AcceptInvitationInput{ - Token: request.Token, AcceptTerms: request.AcceptTerms, AcceptPrivacy: request.AcceptPrivacy, - RequestID: middleware.RequestIDFromContext(c.Request.Context()), - }) - if err != nil { - writeRegistrationError(c, err, "invitation.accept_failed") - return - } - c.JSON(http.StatusOK, session) -} - -func registrationPrincipal(c *gin.Context) (model.Principal, bool) { - principal, ok := identityctx.PrincipalFromContext(c.Request.Context()) - if !ok { - writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required") - } - return principal, ok -} - -func writeRegistrationError(c *gin.Context, err error, fallback string) { - switch { - case errors.Is(err, service.ErrInvitationInvalid): - writeAPIError(c, http.StatusBadRequest, "invitation.invalid", "Invitation is invalid") - case errors.Is(err, service.ErrInvitationRequired): - writeAPIError(c, http.StatusForbidden, "registration.invitation_required", "A workspace invitation is required") - case errors.Is(err, service.ErrRegistrationClosed): - writeAPIError(c, http.StatusForbidden, "registration.closed", "Registration is closed") - case errors.Is(err, service.ErrInsufficientRole): - writeAPIError(c, http.StatusForbidden, "authorization.insufficient_role", "Insufficient workspace role") - case errors.Is(err, repository.ErrNotFound): - writeAPIError(c, http.StatusNotFound, "resource.not_found", "Resource not found") - case errors.Is(err, repository.ErrConflict): - writeAPIError(c, http.StatusConflict, "resource.conflict", "Resource state changed") - case errors.Is(err, service.ErrAccountSuspended): - writeAPIError(c, http.StatusForbidden, "account.suspended", "Account is suspended") - case errors.Is(err, service.ErrAccountDeleted): - writeAPIError(c, http.StatusForbidden, "account.unavailable", "Account is unavailable") - case errors.Is(err, service.ErrOnboardingRequired), errors.Is(err, service.ErrSubjectRequired): - writeAPIError(c, http.StatusForbidden, "account.onboarding_required", "Complete account registration") - case errors.Is(err, service.ErrAgreementsRequired): - writeAPIError(c, http.StatusBadRequest, "registration.agreements_required", "Accept the terms and privacy policy") - default: - writeServiceError(c, err, fallback) - } -} diff --git a/internal/controller/repository.go b/internal/controller/repository.go deleted file mode 100644 index f78c1f0..0000000 --- a/internal/controller/repository.go +++ /dev/null @@ -1,345 +0,0 @@ -package controller - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - identityctx "github.com/mooncode-ai/mooncode/internal/identity" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/service" - "github.com/mooncode-ai/mooncode/pkg/scm" -) - -type RepositoryController struct { - repositories *service.RepositoryService - connections *service.SCMConnectionService -} - -func NewRepositoryController(repositories *service.RepositoryService, connections *service.SCMConnectionService) *RepositoryController { - return &RepositoryController{repositories: repositories, connections: connections} -} - -func (controller *RepositoryController) Register(api *gin.RouterGroup) { - workspaces := api.Group("/workspaces/:workspaceID") - workspaces.GET("/scm-connections", controller.listConnections) - workspaces.POST("/scm-connections", controller.createConnection) - workspaces.PATCH("/scm-connections/:connectionID", controller.updateConnection) - workspaces.DELETE("/scm-connections/:connectionID", controller.deleteConnection) - workspaces.POST("/scm-connections/:connectionID/test", controller.testConnection) - workspaces.GET("/repositories", controller.listRepositories) - workspaces.POST("/repositories", controller.createRepository) - workspaces.GET("/repositories/:repositoryID", controller.getRepository) - workspaces.DELETE("/repositories/:repositoryID", controller.deleteRepository) - workspaces.POST("/repositories/:repositoryID/sync", controller.syncRepository) - workspaces.POST("/repositories/:repositoryID/cancel-sync", controller.cancelRepositorySync) - workspaces.GET("/jobs/:jobID", controller.getJob) - workspaces.POST("/jobs/:jobID/cancel", controller.cancelJob) -} - -func (controller *RepositoryController) deleteConnection(c *gin.Context) { - principal, workspaceID, connectionID, ok := connectionScope(c) - if !ok { - return - } - if err := controller.connections.Delete(c.Request.Context(), principal, workspaceID, connectionID); err != nil { - writeServiceError(c, err, "connection.delete_failed") - return - } - c.Status(http.StatusNoContent) -} - -type createConnectionRequest struct { - Type string `json:"type"` - Name string `json:"name"` - BaseURL string `json:"baseUrl"` - AuthType string `json:"authType"` - Secrets map[string]string `json:"secrets"` -} - -type updateConnectionRequest struct { - Name *string `json:"name"` - BaseURL *string `json:"baseUrl"` - AuthType *string `json:"authType"` - Secrets map[string]string `json:"secrets"` - ClearSecrets bool `json:"clearSecrets"` -} - -func (controller *RepositoryController) updateConnection(c *gin.Context) { - principal, workspaceID, connectionID, ok := connectionScope(c) - if !ok { - return - } - var request updateConnectionRequest - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - value, err := controller.connections.Update(c.Request.Context(), principal, workspaceID, connectionID, service.UpdateSCMConnectionInput{ - Name: request.Name, BaseURL: request.BaseURL, AuthType: request.AuthType, Secrets: request.Secrets, ClearSecrets: request.ClearSecrets, - }) - if err != nil { - writeServiceError(c, err, "connection.update_failed") - return - } - c.JSON(http.StatusOK, value) -} - -func (controller *RepositoryController) testConnection(c *gin.Context) { - principal, workspaceID, connectionID, ok := connectionScope(c) - if !ok { - return - } - var request struct { - RepositoryURL string `json:"repositoryUrl"` - } - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - if err := controller.connections.Test(c.Request.Context(), principal, workspaceID, connectionID, request.RepositoryURL, c.GetHeader("Idempotency-Key")); err != nil { - writeServiceError(c, err, "connection.test_failed") - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -func (controller *RepositoryController) createConnection(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - var request createConnectionRequest - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - value, err := controller.connections.Create(c.Request.Context(), principal, workspaceID, service.CreateSCMConnectionInput{Type: request.Type, Name: request.Name, BaseURL: request.BaseURL, AuthType: request.AuthType, Secrets: request.Secrets}) - if err != nil { - writeServiceError(c, err, "connection.create_failed") - return - } - c.JSON(http.StatusCreated, value) -} -func (controller *RepositoryController) listConnections(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - values, err := controller.connections.List(c.Request.Context(), principal, workspaceID) - if err != nil { - writeServiceError(c, err, "connection.list_failed") - return - } - items, nextCursor, err := paginate(values, c.Query("cursor"), c.Query("pageSize"), func(value model.SCMConnection) pageKey { return pageKey{time: value.CreatedAt, id: value.ID} }) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters") - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor}) -} - -type createRepositoryRequest struct { - Name string `json:"name"` - CloneURL string `json:"cloneUrl"` - ProviderType string `json:"providerType"` - ConnectionID *uuid.UUID `json:"connectionId"` - Ref string `json:"ref"` -} - -func (controller *RepositoryController) createRepository(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - var request createRepositoryRequest - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - value, job, err := controller.repositories.Create(c.Request.Context(), principal, workspaceID, service.CreateRepositoryInput{Name: request.Name, CloneURL: request.CloneURL, ProviderType: request.ProviderType, ConnectionID: request.ConnectionID, Ref: request.Ref, IdempotencyKey: c.GetHeader("Idempotency-Key")}) - if err != nil { - writeServiceError(c, err, "repository.create_failed") - return - } - c.JSON(http.StatusAccepted, gin.H{"repository": value, "job": job}) -} -func (controller *RepositoryController) listRepositories(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - values, err := controller.repositories.List(c.Request.Context(), principal, workspaceID) - if err != nil { - writeServiceError(c, err, "repository.list_failed") - return - } - items, nextCursor, err := paginate(values, c.Query("cursor"), c.Query("pageSize"), func(value model.Repository) pageKey { return pageKey{time: value.CreatedAt, id: value.ID} }) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters") - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor}) -} -func (controller *RepositoryController) getRepository(c *gin.Context) { - principal, workspaceID, resourceID, ok := repositoryScope(c) - if !ok { - return - } - value, err := controller.repositories.Get(c.Request.Context(), principal, workspaceID, resourceID) - if err != nil { - writeServiceError(c, err, "repository.get_failed") - return - } - c.JSON(http.StatusOK, value) -} -func (controller *RepositoryController) deleteRepository(c *gin.Context) { - principal, workspaceID, resourceID, ok := repositoryScope(c) - if !ok { - return - } - if err := controller.repositories.Delete(c.Request.Context(), principal, workspaceID, resourceID); err != nil { - writeServiceError(c, err, "repository.delete_failed") - return - } - c.Status(http.StatusNoContent) -} -func (controller *RepositoryController) syncRepository(c *gin.Context) { - principal, workspaceID, resourceID, ok := repositoryScope(c) - if !ok { - return - } - var request struct { - Ref *string `json:"ref"` - } - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - job, err := controller.repositories.Sync(c.Request.Context(), principal, workspaceID, resourceID, request.Ref, c.GetHeader("Idempotency-Key")) - if err != nil { - writeServiceError(c, err, "repository.sync_failed") - return - } - c.JSON(http.StatusAccepted, gin.H{"job": job}) -} -func (controller *RepositoryController) cancelRepositorySync(c *gin.Context) { - principal, workspaceID, resourceID, ok := repositoryScope(c) - if !ok { - return - } - job, err := controller.repositories.CancelSync(c.Request.Context(), principal, workspaceID, resourceID) - if err != nil { - writeServiceError(c, err, "repository.cancel_failed") - return - } - c.JSON(http.StatusOK, gin.H{"job": job}) -} -func (controller *RepositoryController) getJob(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - jobID, err := uuid.Parse(c.Param("jobID")) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "job.invalid_id", "Invalid job ID") - return - } - value, err := controller.repositories.Job(c.Request.Context(), principal, workspaceID, jobID) - if err != nil { - writeServiceError(c, err, "job.get_failed") - return - } - c.JSON(http.StatusOK, value) -} - -func (controller *RepositoryController) cancelJob(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - jobID, err := uuid.Parse(c.Param("jobID")) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "job.invalid_id", "Invalid job ID") - return - } - value, err := controller.repositories.CancelJob(c.Request.Context(), principal, workspaceID, jobID) - if err != nil { - writeServiceError(c, err, "job.cancel_failed") - return - } - c.JSON(http.StatusOK, value) -} - -func requestScope(c *gin.Context) (model.Principal, uuid.UUID, bool) { - principal, ok := identityctx.PrincipalFromContext(c.Request.Context()) - if !ok { - writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required") - return model.Principal{}, uuid.Nil, false - } - workspaceID, err := uuid.Parse(c.Param("workspaceID")) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "workspace.invalid_id", "Invalid workspace ID") - return model.Principal{}, uuid.Nil, false - } - return principal, workspaceID, true -} -func repositoryScope(c *gin.Context) (model.Principal, uuid.UUID, uuid.UUID, bool) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return model.Principal{}, uuid.Nil, uuid.Nil, false - } - resourceID, err := uuid.Parse(c.Param("repositoryID")) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "repository.invalid_id", "Invalid repository ID") - return model.Principal{}, uuid.Nil, uuid.Nil, false - } - return principal, workspaceID, resourceID, true -} - -func connectionScope(c *gin.Context) (model.Principal, uuid.UUID, uuid.UUID, bool) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return model.Principal{}, uuid.Nil, uuid.Nil, false - } - resourceID, err := uuid.Parse(c.Param("connectionID")) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "connection.invalid_id", "Invalid connection ID") - return model.Principal{}, uuid.Nil, uuid.Nil, false - } - return principal, workspaceID, resourceID, true -} - -func decodeJSON(c *gin.Context, destination any) error { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - decoder := json.NewDecoder(c.Request.Body) - decoder.DisallowUnknownFields() - if err := decoder.Decode(destination); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return errors.New("request must contain one JSON value") - } - return nil -} -func writeServiceError(c *gin.Context, err error, fallback string) { - switch { - case errors.Is(err, repository.ErrNotFound): - writeAPIError(c, http.StatusNotFound, "resource.not_found", "Resource not found") - case errors.Is(err, repository.ErrConflict): - writeAPIError(c, http.StatusConflict, "resource.conflict", "A conflicting operation is already active") - case errors.Is(err, service.ErrInsufficientRole): - writeAPIError(c, http.StatusForbidden, "authorization.insufficient_role", "Insufficient workspace role") - case errors.Is(err, service.ErrOnboardingRequired): - writeAPIError(c, http.StatusForbidden, "account.onboarding_required", "Complete account registration") - case errors.Is(err, scm.ErrInvalidRemote), errors.Is(err, scm.ErrUnsafeAddress), errors.Is(err, scm.ErrUnknownProvider), strings.Contains(err.Error(), "required"): - writeAPIError(c, http.StatusBadRequest, fallback, "Request validation failed") - default: - writeAPIError(c, http.StatusInternalServerError, fallback, "Unable to complete request") - } -} diff --git a/internal/controller/workspace.go b/internal/controller/workspace.go deleted file mode 100644 index 06069a4..0000000 --- a/internal/controller/workspace.go +++ /dev/null @@ -1,130 +0,0 @@ -package controller - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - identityctx "github.com/mooncode-ai/mooncode/internal/identity" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/service" -) - -type WorkspaceController struct{ workspaces *service.WorkspaceService } - -func NewWorkspaceController(workspaces *service.WorkspaceService) *WorkspaceController { - return &WorkspaceController{workspaces: workspaces} -} - -func (controller *WorkspaceController) Register(api *gin.RouterGroup) { - api.POST("/workspaces", controller.create) - api.PATCH("/workspaces/:workspaceID", controller.update) - api.GET("/workspaces/:workspaceID/members", controller.members) - api.PUT("/workspaces/:workspaceID/members/:userID", controller.setMember) - api.DELETE("/workspaces/:workspaceID/members/:userID", controller.removeMember) -} - -func (controller *WorkspaceController) create(c *gin.Context) { - principal, ok := identityctx.PrincipalFromContext(c.Request.Context()) - if !ok { - writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required") - return - } - var request struct { - Name string `json:"name"` - } - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - workspace, err := controller.workspaces.Create(c.Request.Context(), principal, request.Name) - if err != nil { - writeServiceError(c, err, "workspace.create_failed") - return - } - c.JSON(http.StatusCreated, workspace) -} - -func (controller *WorkspaceController) update(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - var request struct { - Name string `json:"name"` - } - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - workspace, err := controller.workspaces.Update(c.Request.Context(), principal, workspaceID, request.Name) - if err != nil { - writeServiceError(c, err, "workspace.update_failed") - return - } - c.JSON(http.StatusOK, workspace) -} - -func (controller *WorkspaceController) members(c *gin.Context) { - principal, workspaceID, ok := requestScope(c) - if !ok { - return - } - members, err := controller.workspaces.Members(c.Request.Context(), principal, workspaceID) - if err != nil { - writeServiceError(c, err, "workspace.members_failed") - return - } - items, nextCursor, err := paginate(members, c.Query("cursor"), c.Query("pageSize"), func(value model.WorkspaceMember) pageKey { - return pageKey{time: value.CreatedAt, id: value.UserID} - }) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters") - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor}) -} - -func (controller *WorkspaceController) setMember(c *gin.Context) { - principal, workspaceID, userID, ok := memberScope(c) - if !ok { - return - } - var request struct { - Role string `json:"role"` - } - if err := decodeJSON(c, &request); err != nil { - writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body") - return - } - if err := controller.workspaces.SetMember(c.Request.Context(), principal, workspaceID, userID, request.Role); err != nil { - writeServiceError(c, err, "workspace.member_failed") - return - } - c.Status(http.StatusNoContent) -} - -func (controller *WorkspaceController) removeMember(c *gin.Context) { - principal, workspaceID, userID, ok := memberScope(c) - if !ok { - return - } - if err := controller.workspaces.RemoveMember(c.Request.Context(), principal, workspaceID, userID); err != nil { - writeServiceError(c, err, "workspace.member_failed") - return - } - c.Status(http.StatusNoContent) -} - -func memberScope(c *gin.Context) (principal model.Principal, workspaceID, userID uuid.UUID, ok bool) { - principal, workspaceID, ok = requestScope(c) - if !ok { - return principal, workspaceID, uuid.Nil, false - } - userID, err := uuid.Parse(c.Param("userID")) - if err != nil { - writeAPIError(c, http.StatusBadRequest, "user.invalid_id", "Invalid user ID") - return principal, workspaceID, uuid.Nil, false - } - return principal, workspaceID, userID, true -} diff --git a/internal/data/pagecursor/pagecursor.go b/internal/data/pagecursor/pagecursor.go new file mode 100644 index 0000000..a93a765 --- /dev/null +++ b/internal/data/pagecursor/pagecursor.go @@ -0,0 +1,15 @@ +package pagecursor + +import ( + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func SQL(request pagination.Request) (pgtype.Timestamptz, uuid.NullUUID) { + if request.After == nil { + return pgtype.Timestamptz{}, uuid.NullUUID{} + } + + return pgtype.Timestamptz{Time: request.After.Time, Valid: true}, uuid.NullUUID{UUID: request.After.ID, Valid: true} +} diff --git a/internal/data/sqlc/analysis.sql.go b/internal/data/sqlc/analysis.sql.go new file mode 100644 index 0000000..918bc3b --- /dev/null +++ b/internal/data/sqlc/analysis.sql.go @@ -0,0 +1,994 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: analysis.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const advanceAnalysisProfile = `-- name: AdvanceAnalysisProfile :one +UPDATE analysis_profiles +SET name=$3, current_version=current_version+1, updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND current_version=$4 AND archived_at IS NULL +RETURNING id, workspace_id, name, current_version, archived_at, created_by, created_at, updated_at +` + +type AdvanceAnalysisProfileParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Name string `json:"name"` + CurrentVersion int32 `json:"current_version"` +} + +func (q *Queries) AdvanceAnalysisProfile(ctx context.Context, arg AdvanceAnalysisProfileParams) (AnalysisProfile, error) { + row := q.db.QueryRow(ctx, advanceAnalysisProfile, + arg.ID, + arg.WorkspaceID, + arg.Name, + arg.CurrentVersion, + ) + var i AnalysisProfile + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.CurrentVersion, + &i.ArchivedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const analysisClock = `-- name: AnalysisClock :one +SELECT clock_timestamp()::timestamptz AS current_time +` + +func (q *Queries) AnalysisClock(ctx context.Context) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, analysisClock) + var current_time pgtype.Timestamptz + err := row.Scan(¤t_time) + return current_time, err +} + +const archiveAnalysisProfile = `-- name: ArchiveAnalysisProfile :execrows +UPDATE analysis_profiles SET archived_at=now(), updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND archived_at IS NULL +` + +type ArchiveAnalysisProfileParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) ArchiveAnalysisProfile(ctx context.Context, arg ArchiveAnalysisProfileParams) (int64, error) { + result, err := q.db.Exec(ctx, archiveAnalysisProfile, arg.ID, arg.WorkspaceID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const cancelAnalysisRun = `-- name: CancelAnalysisRun :one +UPDATE analysis_runs SET status='cancelled', stage='cancelled', retryable=true, finished_at=now() +WHERE id=$1 AND workspace_id=$2 AND status IN ('queued','running') +RETURNING workflow_run_id +` + +type CancelAnalysisRunParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) CancelAnalysisRun(ctx context.Context, arg CancelAnalysisRunParams) (pgtype.Text, error) { + row := q.db.QueryRow(ctx, cancelAnalysisRun, arg.ID, arg.WorkspaceID) + var workflow_run_id pgtype.Text + err := row.Scan(&workflow_run_id) + return workflow_run_id, err +} + +const countActiveAnalysisRuns = `-- name: CountActiveAnalysisRuns :one +SELECT count(*) FROM analysis_runs WHERE workspace_id=$1 AND status IN ('queued','running') +` + +func (q *Queries) CountActiveAnalysisRuns(ctx context.Context, workspaceID uuid.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countActiveAnalysisRuns, workspaceID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createAnalysisProfile = `-- name: CreateAnalysisProfile :one +INSERT INTO analysis_profiles (id, workspace_id, name, current_version, created_by) +VALUES ($1,$2,$3,1,$4) RETURNING id, workspace_id, name, current_version, archived_at, created_by, created_at, updated_at +` + +type CreateAnalysisProfileParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Name string `json:"name"` + CreatedBy uuid.UUID `json:"created_by"` +} + +func (q *Queries) CreateAnalysisProfile(ctx context.Context, arg CreateAnalysisProfileParams) (AnalysisProfile, error) { + row := q.db.QueryRow(ctx, createAnalysisProfile, + arg.ID, + arg.WorkspaceID, + arg.Name, + arg.CreatedBy, + ) + var i AnalysisProfile + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.CurrentVersion, + &i.ArchivedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createAnalysisProfileVersion = `-- name: CreateAnalysisProfileVersion :one +INSERT INTO analysis_profile_versions (id, workspace_id, profile_id, version, dimension_key, definition, created_by) +VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, workspace_id, profile_id, version, dimension_key, definition, created_by, created_at +` + +type CreateAnalysisProfileVersionParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ProfileID uuid.UUID `json:"profile_id"` + Version int32 `json:"version"` + DimensionKey string `json:"dimension_key"` + Definition []byte `json:"definition"` + CreatedBy uuid.UUID `json:"created_by"` +} + +func (q *Queries) CreateAnalysisProfileVersion(ctx context.Context, arg CreateAnalysisProfileVersionParams) (AnalysisProfileVersion, error) { + row := q.db.QueryRow(ctx, createAnalysisProfileVersion, + arg.ID, + arg.WorkspaceID, + arg.ProfileID, + arg.Version, + arg.DimensionKey, + arg.Definition, + arg.CreatedBy, + ) + var i AnalysisProfileVersion + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProfileID, + &i.Version, + &i.DimensionKey, + &i.Definition, + &i.CreatedBy, + &i.CreatedAt, + ) + return i, err +} + +const createAnalysisReport = `-- name: CreateAnalysisReport :one +INSERT INTO analysis_reports ( + id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha, + source_ref, commit_author_name, commit_authored_at, commit_title, + dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment, + started_at, finished_at, duration_ms, result, raw_artifact +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) +RETURNING id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha, source_ref, commit_author_name, commit_authored_at, commit_title, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment, started_at, finished_at, duration_ms, result, raw_artifact, created_at +` + +type CreateAnalysisReportParams struct { + ID uuid.UUID `json:"id"` + AnalysisRunID uuid.UUID `json:"analysis_run_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + RepositoryID uuid.UUID `json:"repository_id"` + SnapshotID uuid.UUID `json:"snapshot_id"` + CommitSha string `json:"commit_sha"` + SourceRef string `json:"source_ref"` + CommitAuthorName pgtype.Text `json:"commit_author_name"` + CommitAuthoredAt pgtype.Timestamptz `json:"commit_authored_at"` + CommitTitle pgtype.Text `json:"commit_title"` + DimensionKey string `json:"dimension_key"` + ProfileID uuid.UUID `json:"profile_id"` + ProfileVersion string `json:"profile_version"` + ProfileSnapshot []byte `json:"profile_snapshot"` + AnalyzerVersion string `json:"analyzer_version"` + ExecutionEnvironment string `json:"execution_environment"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + DurationMs int64 `json:"duration_ms"` + Result []byte `json:"result"` + RawArtifact []byte `json:"raw_artifact"` +} + +func (q *Queries) CreateAnalysisReport(ctx context.Context, arg CreateAnalysisReportParams) (AnalysisReport, error) { + row := q.db.QueryRow(ctx, createAnalysisReport, + arg.ID, + arg.AnalysisRunID, + arg.WorkspaceID, + arg.RepositoryID, + arg.SnapshotID, + arg.CommitSha, + arg.SourceRef, + arg.CommitAuthorName, + arg.CommitAuthoredAt, + arg.CommitTitle, + arg.DimensionKey, + arg.ProfileID, + arg.ProfileVersion, + arg.ProfileSnapshot, + arg.AnalyzerVersion, + arg.ExecutionEnvironment, + arg.StartedAt, + arg.FinishedAt, + arg.DurationMs, + arg.Result, + arg.RawArtifact, + ) + var i AnalysisReport + err := row.Scan( + &i.ID, + &i.AnalysisRunID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.SourceRef, + &i.CommitAuthorName, + &i.CommitAuthoredAt, + &i.CommitTitle, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.ExecutionEnvironment, + &i.StartedAt, + &i.FinishedAt, + &i.DurationMs, + &i.Result, + &i.RawArtifact, + &i.CreatedAt, + ) + return i, err +} + +const createAnalysisRun = `-- name: CreateAnalysisRun :one +INSERT INTO analysis_runs ( + id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, + dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'queued') +ON CONFLICT (workspace_id, idempotency_key, attempt) DO NOTHING +RETURNING id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at +` + +type CreateAnalysisRunParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + RepositoryID uuid.UUID `json:"repository_id"` + SnapshotID uuid.UUID `json:"snapshot_id"` + CommitSha string `json:"commit_sha"` + RequestedBy uuid.UUID `json:"requested_by"` + DimensionKey string `json:"dimension_key"` + ProfileID uuid.UUID `json:"profile_id"` + ProfileVersion string `json:"profile_version"` + ProfileSnapshot []byte `json:"profile_snapshot"` + AnalyzerVersion string `json:"analyzer_version"` + IdempotencyKey string `json:"idempotency_key"` + Attempt int32 `json:"attempt"` + RerunOf uuid.NullUUID `json:"rerun_of"` +} + +func (q *Queries) CreateAnalysisRun(ctx context.Context, arg CreateAnalysisRunParams) (AnalysisRun, error) { + row := q.db.QueryRow(ctx, createAnalysisRun, + arg.ID, + arg.WorkspaceID, + arg.RepositoryID, + arg.SnapshotID, + arg.CommitSha, + arg.RequestedBy, + arg.DimensionKey, + arg.ProfileID, + arg.ProfileVersion, + arg.ProfileSnapshot, + arg.AnalyzerVersion, + arg.IdempotencyKey, + arg.Attempt, + arg.RerunOf, + ) + var i AnalysisRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.RequestedBy, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.IdempotencyKey, + &i.Attempt, + &i.RerunOf, + &i.Status, + &i.Stage, + &i.ReportID, + &i.WorkflowRunID, + &i.FailedStage, + &i.ErrorCode, + &i.ErrorMessage, + &i.Retryable, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const failAnalysisRun = `-- name: FailAnalysisRun :one +UPDATE analysis_runs +SET status='failed', stage=$2, failed_stage=$2, error_code=$3, error_message=$4, + retryable=$5, finished_at=now() +WHERE id=$1 AND status IN ('queued','running') RETURNING id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at +` + +type FailAnalysisRunParams struct { + ID uuid.UUID `json:"id"` + Stage string `json:"stage"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` + Retryable bool `json:"retryable"` +} + +func (q *Queries) FailAnalysisRun(ctx context.Context, arg FailAnalysisRunParams) (AnalysisRun, error) { + row := q.db.QueryRow(ctx, failAnalysisRun, + arg.ID, + arg.Stage, + arg.ErrorCode, + arg.ErrorMessage, + arg.Retryable, + ) + var i AnalysisRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.RequestedBy, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.IdempotencyKey, + &i.Attempt, + &i.RerunOf, + &i.Status, + &i.Stage, + &i.ReportID, + &i.WorkflowRunID, + &i.FailedStage, + &i.ErrorCode, + &i.ErrorMessage, + &i.Retryable, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const finishAnalysisRun = `-- name: FinishAnalysisRun :one +UPDATE analysis_runs +SET status='succeeded', report_id=$2, failed_stage=NULL, error_code=NULL, + error_message=NULL, retryable=false, stage='complete', finished_at=$3 +WHERE id=$1 AND status='running' RETURNING id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at +` + +type FinishAnalysisRunParams struct { + ID uuid.UUID `json:"id"` + ReportID uuid.NullUUID `json:"report_id"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` +} + +func (q *Queries) FinishAnalysisRun(ctx context.Context, arg FinishAnalysisRunParams) (AnalysisRun, error) { + row := q.db.QueryRow(ctx, finishAnalysisRun, arg.ID, arg.ReportID, arg.FinishedAt) + var i AnalysisRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.RequestedBy, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.IdempotencyKey, + &i.Attempt, + &i.RerunOf, + &i.Status, + &i.Stage, + &i.ReportID, + &i.WorkflowRunID, + &i.FailedStage, + &i.ErrorCode, + &i.ErrorMessage, + &i.Retryable, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const getAnalysisProfile = `-- name: GetAnalysisProfile :one +SELECT p.id, p.workspace_id, p.name, p.current_version, p.archived_at, p.created_by, p.created_at, p.updated_at, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at +FROM analysis_profiles p +JOIN analysis_profile_versions v + ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version +WHERE p.id=$1 AND p.workspace_id=$2 AND p.archived_at IS NULL +` + +type GetAnalysisProfileParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +type GetAnalysisProfileRow struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Name string `json:"name"` + CurrentVersion int32 `json:"current_version"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + VersionID uuid.UUID `json:"version_id"` + DimensionKey string `json:"dimension_key"` + Definition []byte `json:"definition"` + VersionCreatedAt pgtype.Timestamptz `json:"version_created_at"` +} + +func (q *Queries) GetAnalysisProfile(ctx context.Context, arg GetAnalysisProfileParams) (GetAnalysisProfileRow, error) { + row := q.db.QueryRow(ctx, getAnalysisProfile, arg.ID, arg.WorkspaceID) + var i GetAnalysisProfileRow + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.CurrentVersion, + &i.ArchivedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.VersionID, + &i.DimensionKey, + &i.Definition, + &i.VersionCreatedAt, + ) + return i, err +} + +const getAnalysisReport = `-- name: GetAnalysisReport :one +SELECT id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha, source_ref, commit_author_name, commit_authored_at, commit_title, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment, started_at, finished_at, duration_ms, result, raw_artifact, created_at FROM analysis_reports WHERE id=$1 AND workspace_id=$2 +` + +type GetAnalysisReportParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) GetAnalysisReport(ctx context.Context, arg GetAnalysisReportParams) (AnalysisReport, error) { + row := q.db.QueryRow(ctx, getAnalysisReport, arg.ID, arg.WorkspaceID) + var i AnalysisReport + err := row.Scan( + &i.ID, + &i.AnalysisRunID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.SourceRef, + &i.CommitAuthorName, + &i.CommitAuthoredAt, + &i.CommitTitle, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.ExecutionEnvironment, + &i.StartedAt, + &i.FinishedAt, + &i.DurationMs, + &i.Result, + &i.RawArtifact, + &i.CreatedAt, + ) + return i, err +} + +const getAnalysisReportByRun = `-- name: GetAnalysisReportByRun :one +SELECT id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha, source_ref, commit_author_name, commit_authored_at, commit_title, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment, started_at, finished_at, duration_ms, result, raw_artifact, created_at FROM analysis_reports WHERE analysis_run_id=$1 +` + +func (q *Queries) GetAnalysisReportByRun(ctx context.Context, analysisRunID uuid.UUID) (AnalysisReport, error) { + row := q.db.QueryRow(ctx, getAnalysisReportByRun, analysisRunID) + var i AnalysisReport + err := row.Scan( + &i.ID, + &i.AnalysisRunID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.SourceRef, + &i.CommitAuthorName, + &i.CommitAuthoredAt, + &i.CommitTitle, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.ExecutionEnvironment, + &i.StartedAt, + &i.FinishedAt, + &i.DurationMs, + &i.Result, + &i.RawArtifact, + &i.CreatedAt, + ) + return i, err +} + +const getAnalysisRun = `-- name: GetAnalysisRun :one +SELECT id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at FROM analysis_runs WHERE id=$1 AND workspace_id=$2 +` + +type GetAnalysisRunParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) GetAnalysisRun(ctx context.Context, arg GetAnalysisRunParams) (AnalysisRun, error) { + row := q.db.QueryRow(ctx, getAnalysisRun, arg.ID, arg.WorkspaceID) + var i AnalysisRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.RequestedBy, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.IdempotencyKey, + &i.Attempt, + &i.RerunOf, + &i.Status, + &i.Stage, + &i.ReportID, + &i.WorkflowRunID, + &i.FailedStage, + &i.ErrorCode, + &i.ErrorMessage, + &i.Retryable, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const getAnalysisRunByAttempt = `-- name: GetAnalysisRunByAttempt :one +SELECT id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at FROM analysis_runs +WHERE workspace_id=$1 AND idempotency_key=$2 AND attempt=$3 +` + +type GetAnalysisRunByAttemptParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + IdempotencyKey string `json:"idempotency_key"` + Attempt int32 `json:"attempt"` +} + +func (q *Queries) GetAnalysisRunByAttempt(ctx context.Context, arg GetAnalysisRunByAttemptParams) (AnalysisRun, error) { + row := q.db.QueryRow(ctx, getAnalysisRunByAttempt, arg.WorkspaceID, arg.IdempotencyKey, arg.Attempt) + var i AnalysisRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.RequestedBy, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.IdempotencyKey, + &i.Attempt, + &i.RerunOf, + &i.Status, + &i.Stage, + &i.ReportID, + &i.WorkflowRunID, + &i.FailedStage, + &i.ErrorCode, + &i.ErrorMessage, + &i.Retryable, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const getAnalysisRunWork = `-- name: GetAnalysisRunWork :one +SELECT id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at FROM analysis_runs WHERE id=$1 +` + +func (q *Queries) GetAnalysisRunWork(ctx context.Context, id uuid.UUID) (AnalysisRun, error) { + row := q.db.QueryRow(ctx, getAnalysisRunWork, id) + var i AnalysisRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.RequestedBy, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.IdempotencyKey, + &i.Attempt, + &i.RerunOf, + &i.Status, + &i.Stage, + &i.ReportID, + &i.WorkflowRunID, + &i.FailedStage, + &i.ErrorCode, + &i.ErrorMessage, + &i.Retryable, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const getDefaultAnalysisProfile = `-- name: GetDefaultAnalysisProfile :one +SELECT p.id, p.workspace_id, p.name, p.current_version, p.archived_at, p.created_by, p.created_at, p.updated_at, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at +FROM analysis_profiles p +JOIN analysis_profile_versions v + ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version +WHERE p.workspace_id=$1 AND p.archived_at IS NULL +ORDER BY p.created_at, p.id +LIMIT 1 +` + +type GetDefaultAnalysisProfileRow struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Name string `json:"name"` + CurrentVersion int32 `json:"current_version"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + VersionID uuid.UUID `json:"version_id"` + DimensionKey string `json:"dimension_key"` + Definition []byte `json:"definition"` + VersionCreatedAt pgtype.Timestamptz `json:"version_created_at"` +} + +func (q *Queries) GetDefaultAnalysisProfile(ctx context.Context, workspaceID uuid.UUID) (GetDefaultAnalysisProfileRow, error) { + row := q.db.QueryRow(ctx, getDefaultAnalysisProfile, workspaceID) + var i GetDefaultAnalysisProfileRow + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.CurrentVersion, + &i.ArchivedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.VersionID, + &i.DimensionKey, + &i.Definition, + &i.VersionCreatedAt, + ) + return i, err +} + +const listAnalysisProfiles = `-- name: ListAnalysisProfiles :many +SELECT p.id, p.workspace_id, p.name, p.current_version, p.archived_at, p.created_by, p.created_at, p.updated_at, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at +FROM analysis_profiles p +JOIN analysis_profile_versions v + ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version +WHERE p.workspace_id=$1 AND p.archived_at IS NULL + AND ( + $2::timestamptz IS NULL + OR (p.created_at, p.id) < ($2, $3::uuid) + ) +ORDER BY p.created_at DESC, p.id DESC +LIMIT $4 +` + +type ListAnalysisProfilesParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +type ListAnalysisProfilesRow struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Name string `json:"name"` + CurrentVersion int32 `json:"current_version"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + VersionID uuid.UUID `json:"version_id"` + DimensionKey string `json:"dimension_key"` + Definition []byte `json:"definition"` + VersionCreatedAt pgtype.Timestamptz `json:"version_created_at"` +} + +func (q *Queries) ListAnalysisProfiles(ctx context.Context, arg ListAnalysisProfilesParams) ([]ListAnalysisProfilesRow, error) { + rows, err := q.db.Query(ctx, listAnalysisProfiles, + arg.WorkspaceID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListAnalysisProfilesRow{} + for rows.Next() { + var i ListAnalysisProfilesRow + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.CurrentVersion, + &i.ArchivedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.VersionID, + &i.DimensionKey, + &i.Definition, + &i.VersionCreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAnalysisRuns = `-- name: ListAnalysisRuns :many +SELECT id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at FROM analysis_runs +WHERE repository_id=$1 + AND workspace_id=$2 + AND ( + $3::timestamptz IS NULL + OR (created_at, id) < ($3, $4::uuid) + ) +ORDER BY created_at DESC, id DESC +LIMIT $5 +` + +type ListAnalysisRunsParams struct { + RepositoryID uuid.UUID `json:"repository_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListAnalysisRuns(ctx context.Context, arg ListAnalysisRunsParams) ([]AnalysisRun, error) { + rows, err := q.db.Query(ctx, listAnalysisRuns, + arg.RepositoryID, + arg.WorkspaceID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AnalysisRun{} + for rows.Next() { + var i AnalysisRun + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.RequestedBy, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.IdempotencyKey, + &i.Attempt, + &i.RerunOf, + &i.Status, + &i.Stage, + &i.ReportID, + &i.WorkflowRunID, + &i.FailedStage, + &i.ErrorCode, + &i.ErrorMessage, + &i.Retryable, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockActiveAnalysisProfiles = `-- name: LockActiveAnalysisProfiles :many +SELECT id FROM analysis_profiles +WHERE workspace_id=$1 AND archived_at IS NULL +ORDER BY id +FOR UPDATE +` + +func (q *Queries) LockActiveAnalysisProfiles(ctx context.Context, workspaceID uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, lockActiveAnalysisProfiles, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []uuid.UUID{} + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockAnalysisSnapshot = `-- name: LockAnalysisSnapshot :one +SELECT snapshot.source_state +FROM commit_snapshots AS snapshot +JOIN repositories AS repository ON repository.id = snapshot.repository_id +WHERE snapshot.id = $1 + AND snapshot.repository_id = $2 + AND repository.workspace_id = $3 +FOR UPDATE OF snapshot +` + +type LockAnalysisSnapshotParams struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) LockAnalysisSnapshot(ctx context.Context, arg LockAnalysisSnapshotParams) (string, error) { + row := q.db.QueryRow(ctx, lockAnalysisSnapshot, arg.ID, arg.RepositoryID, arg.WorkspaceID) + var source_state string + err := row.Scan(&source_state) + return source_state, err +} + +const setAnalysisRunStage = `-- name: SetAnalysisRunStage :execrows +UPDATE analysis_runs SET stage=$2 +WHERE id=$1 AND status='running' +` + +type SetAnalysisRunStageParams struct { + ID uuid.UUID `json:"id"` + Stage string `json:"stage"` +} + +func (q *Queries) SetAnalysisRunStage(ctx context.Context, arg SetAnalysisRunStageParams) (int64, error) { + result, err := q.db.Exec(ctx, setAnalysisRunStage, arg.ID, arg.Stage) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setAnalysisWorkflowID = `-- name: SetAnalysisWorkflowID :exec +UPDATE analysis_runs SET workflow_run_id=$2 WHERE id=$1 +` + +type SetAnalysisWorkflowIDParams struct { + ID uuid.UUID `json:"id"` + WorkflowRunID pgtype.Text `json:"workflow_run_id"` +} + +func (q *Queries) SetAnalysisWorkflowID(ctx context.Context, arg SetAnalysisWorkflowIDParams) error { + _, err := q.db.Exec(ctx, setAnalysisWorkflowID, arg.ID, arg.WorkflowRunID) + return err +} + +const startAnalysisRun = `-- name: StartAnalysisRun :one +UPDATE analysis_runs SET status='running', stage='authorize', started_at=now() +WHERE id=$1 AND status='queued' RETURNING id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at +` + +func (q *Queries) StartAnalysisRun(ctx context.Context, id uuid.UUID) (AnalysisRun, error) { + row := q.db.QueryRow(ctx, startAnalysisRun, id) + var i AnalysisRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.RepositoryID, + &i.SnapshotID, + &i.CommitSha, + &i.RequestedBy, + &i.DimensionKey, + &i.ProfileID, + &i.ProfileVersion, + &i.ProfileSnapshot, + &i.AnalyzerVersion, + &i.IdempotencyKey, + &i.Attempt, + &i.RerunOf, + &i.Status, + &i.Stage, + &i.ReportID, + &i.WorkflowRunID, + &i.FailedStage, + &i.ErrorCode, + &i.ErrorMessage, + &i.Retryable, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} diff --git a/internal/data/sqlc/audit.sql.go b/internal/data/sqlc/audit.sql.go new file mode 100644 index 0000000..8137bca --- /dev/null +++ b/internal/data/sqlc/audit.sql.go @@ -0,0 +1,55 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: audit.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" +) + +const createAuditEvent = `-- name: CreateAuditEvent :exec +INSERT INTO audit_events ( + id, + workspace_id, + actor_user_id, + action, + resource_type, + resource_id, + metadata +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) +` + +type CreateAuditEventParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.NullUUID `json:"workspace_id"` + ActorUserID uuid.NullUUID `json:"actor_user_id"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID uuid.NullUUID `json:"resource_id"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) CreateAuditEvent(ctx context.Context, arg CreateAuditEventParams) error { + _, err := q.db.Exec(ctx, createAuditEvent, + arg.ID, + arg.WorkspaceID, + arg.ActorUserID, + arg.Action, + arg.ResourceType, + arg.ResourceID, + arg.Metadata, + ) + return err +} diff --git a/internal/data/sqlc/channel_commands.sql.go b/internal/data/sqlc/channel_commands.sql.go new file mode 100644 index 0000000..fd6ae8b --- /dev/null +++ b/internal/data/sqlc/channel_commands.sql.go @@ -0,0 +1,247 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: channel_commands.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const checkActiveChannelVersion = `-- name: CheckActiveChannelVersion :one +SELECT config_version FROM channels +WHERE id=$1 AND workspace_id=$2 AND enabled AND config_version=$3 +` + +type CheckActiveChannelVersionParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) CheckActiveChannelVersion(ctx context.Context, arg CheckActiveChannelVersionParams) (int64, error) { + row := q.db.QueryRow(ctx, checkActiveChannelVersion, arg.ID, arg.WorkspaceID, arg.ConfigVersion) + var config_version int64 + err := row.Scan(&config_version) + return config_version, err +} + +const consumeChannelIdentityLink = `-- name: ConsumeChannelIdentityLink :execrows +UPDATE channel_identity_links l SET consumed_at=now() +FROM channels c +WHERE l.id=$1 + AND l.consumed_at IS NULL + AND l.expires_at > now() + AND c.id=l.channel_id + AND c.enabled + AND c.config_version=l.channel_version +` + +func (q *Queries) ConsumeChannelIdentityLink(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.Exec(ctx, consumeChannelIdentityLink, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const createChannelIdentityLink = `-- name: CreateChannelIdentityLink :one +INSERT INTO channel_identity_links ( + id, workspace_id, channel_id, channel_version, sender_canonical_id, token_hash, expires_at +) +SELECT $1, $2, $3, + $4, $5, + $6, $7 +FROM channels +WHERE id=$3 + AND workspace_id=$2 + AND enabled + AND config_version=$4 +RETURNING id, workspace_id, channel_id, channel_version, sender_canonical_id, token_hash, expires_at, consumed_at, created_at +` + +type CreateChannelIdentityLinkParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + ChannelVersion int64 `json:"channel_version"` + SenderCanonicalID string `json:"sender_canonical_id"` + TokenHash []byte `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreateChannelIdentityLink(ctx context.Context, arg CreateChannelIdentityLinkParams) (ChannelIdentityLink, error) { + row := q.db.QueryRow(ctx, createChannelIdentityLink, + arg.ID, + arg.WorkspaceID, + arg.ChannelID, + arg.ChannelVersion, + arg.SenderCanonicalID, + arg.TokenHash, + arg.ExpiresAt, + ) + var i ChannelIdentityLink + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ChannelID, + &i.ChannelVersion, + &i.SenderCanonicalID, + &i.TokenHash, + &i.ExpiresAt, + &i.ConsumedAt, + &i.CreatedAt, + ) + return i, err +} + +const deleteConversationBinding = `-- name: DeleteConversationBinding :execrows +DELETE FROM conversation_bindings WHERE conversation_id=$1 +` + +func (q *Queries) DeleteConversationBinding(ctx context.Context, conversationID uuid.UUID) (int64, error) { + result, err := q.db.Exec(ctx, deleteConversationBinding, conversationID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getChannelExternalIdentity = `-- name: GetChannelExternalIdentity :one +SELECT user_id FROM channel_external_identities +WHERE channel_id=$1 AND sender_canonical_id=$2 +` + +type GetChannelExternalIdentityParams struct { + ChannelID uuid.UUID `json:"channel_id"` + SenderCanonicalID string `json:"sender_canonical_id"` +} + +func (q *Queries) GetChannelExternalIdentity(ctx context.Context, arg GetChannelExternalIdentityParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, getChannelExternalIdentity, arg.ChannelID, arg.SenderCanonicalID) + var user_id uuid.UUID + err := row.Scan(&user_id) + return user_id, err +} + +const getChannelIdentityLink = `-- name: GetChannelIdentityLink :one +SELECT l.id, l.workspace_id, l.channel_id, l.channel_version, l.sender_canonical_id, l.token_hash, l.expires_at, l.consumed_at, l.created_at FROM channel_identity_links l +JOIN channels c ON c.id=l.channel_id +WHERE l.token_hash=$1 + AND l.consumed_at IS NULL + AND l.expires_at > now() + AND c.enabled + AND c.config_version=l.channel_version +` + +func (q *Queries) GetChannelIdentityLink(ctx context.Context, tokenHash []byte) (ChannelIdentityLink, error) { + row := q.db.QueryRow(ctx, getChannelIdentityLink, tokenHash) + var i ChannelIdentityLink + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ChannelID, + &i.ChannelVersion, + &i.SenderCanonicalID, + &i.TokenHash, + &i.ExpiresAt, + &i.ConsumedAt, + &i.CreatedAt, + ) + return i, err +} + +const getConversationBinding = `-- name: GetConversationBinding :one +SELECT repository_id FROM conversation_bindings WHERE conversation_id=$1 +` + +func (q *Queries) GetConversationBinding(ctx context.Context, conversationID uuid.UUID) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, getConversationBinding, conversationID) + var repository_id uuid.UUID + err := row.Scan(&repository_id) + return repository_id, err +} + +const getConversationIDByExternal = `-- name: GetConversationIDByExternal :one +SELECT conversation.id FROM conversations AS conversation +JOIN channels AS channel ON channel.id=conversation.channel_id +WHERE conversation.channel_id=$1 + AND conversation.external_id=$2 + AND channel.workspace_id=$3 + AND channel.enabled + AND channel.config_version=$4 +` + +type GetConversationIDByExternalParams struct { + ChannelID uuid.UUID `json:"channel_id"` + ExternalID string `json:"external_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelVersion int64 `json:"channel_version"` +} + +func (q *Queries) GetConversationIDByExternal(ctx context.Context, arg GetConversationIDByExternalParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, getConversationIDByExternal, + arg.ChannelID, + arg.ExternalID, + arg.WorkspaceID, + arg.ChannelVersion, + ) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + +const upsertChannelExternalIdentity = `-- name: UpsertChannelExternalIdentity :exec +INSERT INTO channel_external_identities ( + id, workspace_id, channel_id, sender_canonical_id, user_id +) VALUES ($1,$2,$3,$4,$5) +ON CONFLICT (channel_id, sender_canonical_id) DO UPDATE +SET user_id=excluded.user_id +` + +type UpsertChannelExternalIdentityParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + SenderCanonicalID string `json:"sender_canonical_id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) UpsertChannelExternalIdentity(ctx context.Context, arg UpsertChannelExternalIdentityParams) error { + _, err := q.db.Exec(ctx, upsertChannelExternalIdentity, + arg.ID, + arg.WorkspaceID, + arg.ChannelID, + arg.SenderCanonicalID, + arg.UserID, + ) + return err +} + +const upsertConversationBinding = `-- name: UpsertConversationBinding :exec +INSERT INTO conversation_bindings (conversation_id, workspace_id, repository_id, bound_by) +VALUES ($1,$2,$3,$4) +ON CONFLICT (conversation_id) DO UPDATE +SET repository_id=excluded.repository_id, bound_by=excluded.bound_by, updated_at=now() +` + +type UpsertConversationBindingParams struct { + ConversationID uuid.UUID `json:"conversation_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + RepositoryID uuid.UUID `json:"repository_id"` + BoundBy uuid.UUID `json:"bound_by"` +} + +func (q *Queries) UpsertConversationBinding(ctx context.Context, arg UpsertConversationBindingParams) error { + _, err := q.db.Exec(ctx, upsertConversationBinding, + arg.ConversationID, + arg.WorkspaceID, + arg.RepositoryID, + arg.BoundBy, + ) + return err +} diff --git a/internal/data/sqlc/channels.sql.go b/internal/data/sqlc/channels.sql.go new file mode 100644 index 0000000..f9c4261 --- /dev/null +++ b/internal/data/sqlc/channels.sql.go @@ -0,0 +1,759 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: channels.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createChannel = `-- name: CreateChannel :one +INSERT INTO channels (id, workspace_id, type, name, enabled, secret_ciphertext, secret_nonce, key_version, config) +VALUES ($1,$2,$3,$4,false,$5,$6,$7,$8) RETURNING id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at +` + +type CreateChannelParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Type string `json:"type"` + Name string `json:"name"` + SecretCiphertext []byte `json:"secret_ciphertext"` + SecretNonce []byte `json:"secret_nonce"` + KeyVersion pgtype.Int4 `json:"key_version"` + Config []byte `json:"config"` +} + +func (q *Queries) CreateChannel(ctx context.Context, arg CreateChannelParams) (Channel, error) { + row := q.db.QueryRow(ctx, createChannel, + arg.ID, + arg.WorkspaceID, + arg.Type, + arg.Name, + arg.SecretCiphertext, + arg.SecretNonce, + arg.KeyVersion, + arg.Config, + ) + var i Channel + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Type, + &i.Name, + &i.Enabled, + &i.RuntimeStatus, + &i.SecretCiphertext, + &i.SecretNonce, + &i.KeyVersion, + &i.ConfigVersion, + &i.Config, + &i.LastConnectedAt, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createChannelSubscription = `-- name: CreateChannelSubscription :exec +INSERT INTO channel_subscriptions (id, workspace_id, channel_id, event_type) +VALUES ($1,$2,$3,$4) +` + +type CreateChannelSubscriptionParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + EventType string `json:"event_type"` +} + +func (q *Queries) CreateChannelSubscription(ctx context.Context, arg CreateChannelSubscriptionParams) error { + _, err := q.db.Exec(ctx, createChannelSubscription, + arg.ID, + arg.WorkspaceID, + arg.ChannelID, + arg.EventType, + ) + return err +} + +const deleteChannel = `-- name: DeleteChannel :execrows +DELETE FROM channels WHERE id=$1 AND workspace_id=$2 +` + +type DeleteChannelParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteChannel(ctx context.Context, arg DeleteChannelParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteChannel, arg.ID, arg.WorkspaceID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const deleteChannelSubscriptions = `-- name: DeleteChannelSubscriptions :exec +DELETE FROM channel_subscriptions WHERE workspace_id=$1 AND channel_id=$2 +` + +type DeleteChannelSubscriptionsParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` +} + +func (q *Queries) DeleteChannelSubscriptions(ctx context.Context, arg DeleteChannelSubscriptionsParams) error { + _, err := q.db.Exec(ctx, deleteChannelSubscriptions, arg.WorkspaceID, arg.ChannelID) + return err +} + +const getChannel = `-- name: GetChannel :one +SELECT id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at FROM channels WHERE id=$1 AND workspace_id=$2 +` + +type GetChannelParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) GetChannel(ctx context.Context, arg GetChannelParams) (Channel, error) { + row := q.db.QueryRow(ctx, getChannel, arg.ID, arg.WorkspaceID) + var i Channel + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Type, + &i.Name, + &i.Enabled, + &i.RuntimeStatus, + &i.SecretCiphertext, + &i.SecretNonce, + &i.KeyVersion, + &i.ConfigVersion, + &i.Config, + &i.LastConnectedAt, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getWorkspaceOverview = `-- name: GetWorkspaceOverview :one +SELECT + (SELECT count(*) FROM repositories rr WHERE rr.workspace_id=$1 AND rr.status <> 'deleted') AS repository_count, + (SELECT count(*) FROM repositories rr WHERE rr.workspace_id=$1 AND rr.status <> 'deleted' AND rr.current_snapshot_id IS NULL) AS repository_without_code_count, + (SELECT count(*) FROM repository_operations o JOIN repositories r ON r.id=o.repository_id WHERE r.workspace_id=$1 AND o.status='failed' AND o.created_at > now()-interval '7 days') AS recent_failed_sync_count, + (SELECT count(*) FROM analysis_runs ar WHERE ar.workspace_id=$1 AND ar.status IN ('queued','running')) AS active_analysis_count, + (SELECT count(*) FROM channels cc WHERE cc.workspace_id=$1 AND cc.enabled) AS active_channel_count +` + +type GetWorkspaceOverviewRow struct { + RepositoryCount int64 `json:"repository_count"` + RepositoryWithoutCodeCount int64 `json:"repository_without_code_count"` + RecentFailedSyncCount int64 `json:"recent_failed_sync_count"` + ActiveAnalysisCount int64 `json:"active_analysis_count"` + ActiveChannelCount int64 `json:"active_channel_count"` +} + +func (q *Queries) GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (GetWorkspaceOverviewRow, error) { + row := q.db.QueryRow(ctx, getWorkspaceOverview, workspaceID) + var i GetWorkspaceOverviewRow + err := row.Scan( + &i.RepositoryCount, + &i.RepositoryWithoutCodeCount, + &i.RecentFailedSyncCount, + &i.ActiveAnalysisCount, + &i.ActiveChannelCount, + ) + return i, err +} + +const insertInboundMessage = `-- name: InsertInboundMessage :execrows +INSERT INTO messages ( + id, workspace_id, channel_id, conversation_id, external_id, + sender_canonical_id, sender_display_name, content, occurred_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) +ON CONFLICT (channel_id, external_id) DO NOTHING +` + +type InsertInboundMessageParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + ConversationID uuid.UUID `json:"conversation_id"` + ExternalID string `json:"external_id"` + SenderCanonicalID string `json:"sender_canonical_id"` + SenderDisplayName string `json:"sender_display_name"` + Content []byte `json:"content"` + OccurredAt pgtype.Timestamptz `json:"occurred_at"` +} + +func (q *Queries) InsertInboundMessage(ctx context.Context, arg InsertInboundMessageParams) (int64, error) { + result, err := q.db.Exec(ctx, insertInboundMessage, + arg.ID, + arg.WorkspaceID, + arg.ChannelID, + arg.ConversationID, + arg.ExternalID, + arg.SenderCanonicalID, + arg.SenderDisplayName, + arg.Content, + arg.OccurredAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const listChannelSubscriptions = `-- name: ListChannelSubscriptions :many +SELECT channel_id, event_type FROM channel_subscriptions +WHERE workspace_id=$1 +ORDER BY channel_id, event_type +` + +type ListChannelSubscriptionsRow struct { + ChannelID uuid.UUID `json:"channel_id"` + EventType string `json:"event_type"` +} + +func (q *Queries) ListChannelSubscriptions(ctx context.Context, workspaceID uuid.UUID) ([]ListChannelSubscriptionsRow, error) { + rows, err := q.db.Query(ctx, listChannelSubscriptions, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListChannelSubscriptionsRow{} + for rows.Next() { + var i ListChannelSubscriptionsRow + if err := rows.Scan(&i.ChannelID, &i.EventType); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listChannels = `-- name: ListChannels :many +SELECT id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at FROM channels +WHERE workspace_id=$1 + AND ( + $2::timestamptz IS NULL + OR (created_at, id) < ($2, $3::uuid) + ) +ORDER BY created_at DESC, id DESC +LIMIT $4 +` + +type ListChannelsParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListChannels(ctx context.Context, arg ListChannelsParams) ([]Channel, error) { + rows, err := q.db.Query(ctx, listChannels, + arg.WorkspaceID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Channel{} + for rows.Next() { + var i Channel + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Type, + &i.Name, + &i.Enabled, + &i.RuntimeStatus, + &i.SecretCiphertext, + &i.SecretNonce, + &i.KeyVersion, + &i.ConfigVersion, + &i.Config, + &i.LastConnectedAt, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listConversations = `-- name: ListConversations :many +SELECT c.id, c.workspace_id, c.channel_id, c.external_id, c.type, c.title, c.created_at, + ch.name AS channel_name, ch.type AS channel_type +FROM conversations c JOIN channels ch ON ch.id=c.channel_id +WHERE c.workspace_id=$1 + AND ( + $2::timestamptz IS NULL + OR (c.created_at, c.id) < ($2, $3::uuid) + ) +ORDER BY c.created_at DESC, c.id DESC +LIMIT $4 +` + +type ListConversationsParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +type ListConversationsRow struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + ExternalID string `json:"external_id"` + Type string `json:"type"` + Title string `json:"title"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ChannelName string `json:"channel_name"` + ChannelType string `json:"channel_type"` +} + +func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsParams) ([]ListConversationsRow, error) { + rows, err := q.db.Query(ctx, listConversations, + arg.WorkspaceID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListConversationsRow{} + for rows.Next() { + var i ListConversationsRow + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.ChannelID, + &i.ExternalID, + &i.Type, + &i.Title, + &i.CreatedAt, + &i.ChannelName, + &i.ChannelType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMessages = `-- name: ListMessages :many +SELECT m.id, m.workspace_id, m.channel_id, m.conversation_id, m.external_id, m.sender_canonical_id, m.sender_display_name, m.content, m.occurred_at, m.created_at, c.external_id AS conversation_external_id, c.type AS conversation_type, + ch.name AS channel_name, ch.type AS channel_type +FROM messages m +JOIN conversations c ON c.id=m.conversation_id +JOIN channels ch ON ch.id=m.channel_id +WHERE m.workspace_id=$1 + AND ($2::uuid IS NULL OR m.channel_id=$2) + AND ($3::uuid IS NULL OR m.conversation_id=$3) + AND ( + $4::timestamptz IS NULL + OR (m.occurred_at, m.id) < ($4, $5::uuid) + ) +ORDER BY m.occurred_at DESC, m.id DESC +LIMIT $6 +` + +type ListMessagesParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.NullUUID `json:"channel_id"` + ConversationID uuid.NullUUID `json:"conversation_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +type ListMessagesRow struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + ConversationID uuid.UUID `json:"conversation_id"` + ExternalID string `json:"external_id"` + SenderCanonicalID string `json:"sender_canonical_id"` + SenderDisplayName string `json:"sender_display_name"` + Content []byte `json:"content"` + OccurredAt pgtype.Timestamptz `json:"occurred_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ConversationExternalID string `json:"conversation_external_id"` + ConversationType string `json:"conversation_type"` + ChannelName string `json:"channel_name"` + ChannelType string `json:"channel_type"` +} + +func (q *Queries) ListMessages(ctx context.Context, arg ListMessagesParams) ([]ListMessagesRow, error) { + rows, err := q.db.Query(ctx, listMessages, + arg.WorkspaceID, + arg.ChannelID, + arg.ConversationID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMessagesRow{} + for rows.Next() { + var i ListMessagesRow + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.ChannelID, + &i.ConversationID, + &i.ExternalID, + &i.SenderCanonicalID, + &i.SenderDisplayName, + &i.Content, + &i.OccurredAt, + &i.CreatedAt, + &i.ConversationExternalID, + &i.ConversationType, + &i.ChannelName, + &i.ChannelType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRuntimeChannels = `-- name: ListRuntimeChannels :many +SELECT id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at FROM channels +WHERE enabled AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND key_version IS NOT NULL +ORDER BY created_at +` + +func (q *Queries) ListRuntimeChannels(ctx context.Context) ([]Channel, error) { + rows, err := q.db.Query(ctx, listRuntimeChannels) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Channel{} + for rows.Next() { + var i Channel + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Type, + &i.Name, + &i.Enabled, + &i.RuntimeStatus, + &i.SecretCiphertext, + &i.SecretNonce, + &i.KeyVersion, + &i.ConfigVersion, + &i.Config, + &i.LastConnectedAt, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSubscribedChannels = `-- name: ListSubscribedChannels :many +SELECT c.id, c.workspace_id, c.type, c.name, c.enabled, c.runtime_status, c.secret_ciphertext, c.secret_nonce, c.key_version, c.config_version, c.config, c.last_connected_at, c.last_error_message, c.created_at, c.updated_at FROM channels c +JOIN channel_subscriptions subscription ON subscription.channel_id=c.id AND subscription.workspace_id=c.workspace_id +WHERE c.workspace_id=$1 AND c.enabled AND subscription.event_type=$2 +ORDER BY c.created_at +` + +type ListSubscribedChannelsParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + EventType string `json:"event_type"` +} + +func (q *Queries) ListSubscribedChannels(ctx context.Context, arg ListSubscribedChannelsParams) ([]Channel, error) { + rows, err := q.db.Query(ctx, listSubscribedChannels, arg.WorkspaceID, arg.EventType) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Channel{} + for rows.Next() { + var i Channel + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Type, + &i.Name, + &i.Enabled, + &i.RuntimeStatus, + &i.SecretCiphertext, + &i.SecretNonce, + &i.KeyVersion, + &i.ConfigVersion, + &i.Config, + &i.LastConnectedAt, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockChannelRuntime = `-- name: LockChannelRuntime :one +SELECT config_version FROM channels +WHERE id=$1 AND workspace_id=$2 AND enabled +FOR KEY SHARE +` + +type LockChannelRuntimeParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) LockChannelRuntime(ctx context.Context, arg LockChannelRuntimeParams) (int64, error) { + row := q.db.QueryRow(ctx, lockChannelRuntime, arg.ID, arg.WorkspaceID) + var config_version int64 + err := row.Scan(&config_version) + return config_version, err +} + +const markChannelRuntimeConnected = `-- name: MarkChannelRuntimeConnected :execrows +UPDATE channels SET runtime_status='connected', last_connected_at=now(), last_error_message=NULL, updated_at=now() +WHERE id=$1 AND enabled AND config_version=$2 +` + +type MarkChannelRuntimeConnectedParams struct { + ID uuid.UUID `json:"id"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) MarkChannelRuntimeConnected(ctx context.Context, arg MarkChannelRuntimeConnectedParams) (int64, error) { + result, err := q.db.Exec(ctx, markChannelRuntimeConnected, arg.ID, arg.ConfigVersion) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const markChannelRuntimeError = `-- name: MarkChannelRuntimeError :execrows +UPDATE channels SET runtime_status='error', last_error_message=$3, updated_at=now() +WHERE id=$1 AND enabled AND config_version=$2 +` + +type MarkChannelRuntimeErrorParams struct { + ID uuid.UUID `json:"id"` + ConfigVersion int64 `json:"config_version"` + LastErrorMessage pgtype.Text `json:"last_error_message"` +} + +func (q *Queries) MarkChannelRuntimeError(ctx context.Context, arg MarkChannelRuntimeErrorParams) (int64, error) { + result, err := q.db.Exec(ctx, markChannelRuntimeError, arg.ID, arg.ConfigVersion, arg.LastErrorMessage) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const rotateChannelCredential = `-- name: RotateChannelCredential :one +UPDATE channels SET secret_ciphertext=$3, secret_nonce=$4, key_version=$5, + config_version=config_version+1, + runtime_status=CASE WHEN enabled THEN 'starting' ELSE 'disabled' END, + last_error_message=NULL, updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND config_version=$6 RETURNING id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at +` + +type RotateChannelCredentialParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + SecretCiphertext []byte `json:"secret_ciphertext"` + SecretNonce []byte `json:"secret_nonce"` + KeyVersion pgtype.Int4 `json:"key_version"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) RotateChannelCredential(ctx context.Context, arg RotateChannelCredentialParams) (Channel, error) { + row := q.db.QueryRow(ctx, rotateChannelCredential, + arg.ID, + arg.WorkspaceID, + arg.SecretCiphertext, + arg.SecretNonce, + arg.KeyVersion, + arg.ConfigVersion, + ) + var i Channel + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Type, + &i.Name, + &i.Enabled, + &i.RuntimeStatus, + &i.SecretCiphertext, + &i.SecretNonce, + &i.KeyVersion, + &i.ConfigVersion, + &i.Config, + &i.LastConnectedAt, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const setChannelEnabled = `-- name: SetChannelEnabled :one +UPDATE channels SET enabled=$3, runtime_status=CASE WHEN $3 THEN 'starting' ELSE 'disabled' END, + last_error_message=CASE WHEN $3 THEN NULL ELSE last_error_message END, updated_at=now() +WHERE id=$1 AND workspace_id=$2 RETURNING id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at +` + +type SetChannelEnabledParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Enabled bool `json:"enabled"` +} + +func (q *Queries) SetChannelEnabled(ctx context.Context, arg SetChannelEnabledParams) (Channel, error) { + row := q.db.QueryRow(ctx, setChannelEnabled, arg.ID, arg.WorkspaceID, arg.Enabled) + var i Channel + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Type, + &i.Name, + &i.Enabled, + &i.RuntimeStatus, + &i.SecretCiphertext, + &i.SecretNonce, + &i.KeyVersion, + &i.ConfigVersion, + &i.Config, + &i.LastConnectedAt, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateChannel = `-- name: UpdateChannel :one +UPDATE channels SET name=$3, config=$4, config_version=config_version+1, + runtime_status=CASE WHEN enabled THEN 'starting' ELSE 'disabled' END, + last_error_message=NULL, updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND config_version=$5 RETURNING id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at +` + +type UpdateChannelParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Name string `json:"name"` + Config []byte `json:"config"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) UpdateChannel(ctx context.Context, arg UpdateChannelParams) (Channel, error) { + row := q.db.QueryRow(ctx, updateChannel, + arg.ID, + arg.WorkspaceID, + arg.Name, + arg.Config, + arg.ConfigVersion, + ) + var i Channel + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Type, + &i.Name, + &i.Enabled, + &i.RuntimeStatus, + &i.SecretCiphertext, + &i.SecretNonce, + &i.KeyVersion, + &i.ConfigVersion, + &i.Config, + &i.LastConnectedAt, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertConversation = `-- name: UpsertConversation :one +INSERT INTO conversations (id, workspace_id, channel_id, external_id, type, title) +VALUES ($1,$2,$3,$4,$5,$6) +ON CONFLICT (channel_id, external_id) DO UPDATE +SET type=excluded.type, title=excluded.title +RETURNING id +` + +type UpsertConversationParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + ExternalID string `json:"external_id"` + Type string `json:"type"` + Title string `json:"title"` +} + +func (q *Queries) UpsertConversation(ctx context.Context, arg UpsertConversationParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, upsertConversation, + arg.ID, + arg.WorkspaceID, + arg.ChannelID, + arg.ExternalID, + arg.Type, + arg.Title, + ) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} diff --git a/internal/repository/postgres/sqlc/db.go b/internal/data/sqlc/db.go similarity index 97% rename from internal/repository/postgres/sqlc/db.go rename to internal/data/sqlc/db.go index 0e4c3f4..2725108 100644 --- a/internal/repository/postgres/sqlc/db.go +++ b/internal/data/sqlc/db.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.29.0 -package db +package sqlc import ( "context" diff --git a/internal/data/sqlc/identity.sql.go b/internal/data/sqlc/identity.sql.go new file mode 100644 index 0000000..6116261 --- /dev/null +++ b/internal/data/sqlc/identity.sql.go @@ -0,0 +1,581 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: identity.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const acceptWorkspaceInvitation = `-- name: AcceptWorkspaceInvitation :execrows +UPDATE workspace_invitations SET accepted_at = now() +WHERE id = $1 AND accepted_at IS NULL AND revoked_at IS NULL +` + +func (q *Queries) AcceptWorkspaceInvitation(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.Exec(ctx, acceptWorkspaceInvitation, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const activateUser = `-- name: ActivateUser :one +UPDATE users +SET status = 'active', accepted_terms_at = now(), accepted_privacy_at = now(), updated_at = now() +WHERE id = $1 AND status = 'pending' +RETURNING id, display_name, username, email, status, accepted_terms_at, accepted_privacy_at, created_at, updated_at +` + +func (q *Queries) ActivateUser(ctx context.Context, id uuid.UUID) (User, error) { + row := q.db.QueryRow(ctx, activateUser, id) + var i User + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.Username, + &i.Email, + &i.Status, + &i.AcceptedTermsAt, + &i.AcceptedPrivacyAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const addWorkspaceMember = `-- name: AddWorkspaceMember :exec +INSERT INTO workspace_members (workspace_id, user_id, role) +VALUES ($1, $2, $3) +ON CONFLICT (workspace_id, user_id) DO NOTHING +` + +type AddWorkspaceMemberParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + UserID uuid.UUID `json:"user_id"` + Role string `json:"role"` +} + +func (q *Queries) AddWorkspaceMember(ctx context.Context, arg AddWorkspaceMemberParams) error { + _, err := q.db.Exec(ctx, addWorkspaceMember, arg.WorkspaceID, arg.UserID, arg.Role) + return err +} + +const createOAuthIdentity = `-- name: CreateOAuthIdentity :exec +INSERT INTO oauth_identities (issuer, subject, user_id) +VALUES ($1, $2, $3) +` + +type CreateOAuthIdentityParams struct { + Issuer string `json:"issuer"` + Subject string `json:"subject"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) CreateOAuthIdentity(ctx context.Context, arg CreateOAuthIdentityParams) error { + _, err := q.db.Exec(ctx, createOAuthIdentity, arg.Issuer, arg.Subject, arg.UserID) + return err +} + +const createRetentionCleanupSchedule = `-- name: CreateRetentionCleanupSchedule :exec +INSERT INTO retention_cleanup_schedules (workspace_id) +VALUES ($1) +` + +func (q *Queries) CreateRetentionCleanupSchedule(ctx context.Context, workspaceID uuid.UUID) error { + _, err := q.db.Exec(ctx, createRetentionCleanupSchedule, workspaceID) + return err +} + +const createUser = `-- name: CreateUser :one +INSERT INTO users (id, display_name, username, email, status) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, display_name, username, email, status, accepted_terms_at, accepted_privacy_at, created_at, updated_at +` + +type CreateUserParams struct { + ID uuid.UUID `json:"id"` + DisplayName string `json:"display_name"` + Username string `json:"username"` + Email string `json:"email"` + Status string `json:"status"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, + arg.ID, + arg.DisplayName, + arg.Username, + arg.Email, + arg.Status, + ) + var i User + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.Username, + &i.Email, + &i.Status, + &i.AcceptedTermsAt, + &i.AcceptedPrivacyAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createWorkspace = `-- name: CreateWorkspace :one +INSERT INTO workspaces (id, name, slug, created_by) +VALUES ($1, $2, $3, $4) +RETURNING id, name, slug, report_retention_days, created_by, created_at, updated_at +` + +type CreateWorkspaceParams struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CreatedBy uuid.UUID `json:"created_by"` +} + +func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) { + row := q.db.QueryRow(ctx, createWorkspace, + arg.ID, + arg.Name, + arg.Slug, + arg.CreatedBy, + ) + var i Workspace + err := row.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.ReportRetentionDays, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createWorkspaceInvitation = `-- name: CreateWorkspaceInvitation :one +INSERT INTO workspace_invitations (id, workspace_id, email, token_hash, created_by, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, workspace_id, email, token_hash, created_by, expires_at, accepted_at, revoked_at, created_at +` + +type CreateWorkspaceInvitationParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Email string `json:"email"` + TokenHash []byte `json:"token_hash"` + CreatedBy uuid.UUID `json:"created_by"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreateWorkspaceInvitation(ctx context.Context, arg CreateWorkspaceInvitationParams) (WorkspaceInvitation, error) { + row := q.db.QueryRow(ctx, createWorkspaceInvitation, + arg.ID, + arg.WorkspaceID, + arg.Email, + arg.TokenHash, + arg.CreatedBy, + arg.ExpiresAt, + ) + var i WorkspaceInvitation + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Email, + &i.TokenHash, + &i.CreatedBy, + &i.ExpiresAt, + &i.AcceptedAt, + &i.RevokedAt, + &i.CreatedAt, + ) + return i, err +} + +const getInvitationByTokenHash = `-- name: GetInvitationByTokenHash :one +SELECT id, workspace_id, email, token_hash, created_by, expires_at, accepted_at, revoked_at, created_at FROM workspace_invitations +WHERE token_hash = $1 AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now() +` + +func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash []byte) (WorkspaceInvitation, error) { + row := q.db.QueryRow(ctx, getInvitationByTokenHash, tokenHash) + var i WorkspaceInvitation + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Email, + &i.TokenHash, + &i.CreatedBy, + &i.ExpiresAt, + &i.AcceptedAt, + &i.RevokedAt, + &i.CreatedAt, + ) + return i, err +} + +const getUser = `-- name: GetUser :one +SELECT id, display_name, username, email, status, accepted_terms_at, accepted_privacy_at, created_at, updated_at FROM users WHERE id = $1 +` + +func (q *Queries) GetUser(ctx context.Context, id uuid.UUID) (User, error) { + row := q.db.QueryRow(ctx, getUser, id) + var i User + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.Username, + &i.Email, + &i.Status, + &i.AcceptedTermsAt, + &i.AcceptedPrivacyAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByIdentity = `-- name: GetUserByIdentity :one +SELECT u.id, u.display_name, u.username, u.email, u.status, u.accepted_terms_at, u.accepted_privacy_at, u.created_at, u.updated_at FROM users u +JOIN oauth_identities oi ON oi.user_id = u.id +WHERE oi.issuer = $1 AND oi.subject = $2 +` + +type GetUserByIdentityParams struct { + Issuer string `json:"issuer"` + Subject string `json:"subject"` +} + +func (q *Queries) GetUserByIdentity(ctx context.Context, arg GetUserByIdentityParams) (User, error) { + row := q.db.QueryRow(ctx, getUserByIdentity, arg.Issuer, arg.Subject) + var i User + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.Username, + &i.Email, + &i.Status, + &i.AcceptedTermsAt, + &i.AcceptedPrivacyAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getWorkspaceMembership = `-- name: GetWorkspaceMembership :one +SELECT w.id, w.name, w.slug, w.report_retention_days, w.created_by, w.created_at, w.updated_at, wm.role FROM workspaces w +JOIN workspace_members wm ON wm.workspace_id = w.id +JOIN users u ON u.id = wm.user_id AND u.status = 'active' +WHERE w.id = $1 AND wm.user_id = $2 +` + +type GetWorkspaceMembershipParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +type GetWorkspaceMembershipRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + ReportRetentionDays int32 `json:"report_retention_days"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Role string `json:"role"` +} + +func (q *Queries) GetWorkspaceMembership(ctx context.Context, arg GetWorkspaceMembershipParams) (GetWorkspaceMembershipRow, error) { + row := q.db.QueryRow(ctx, getWorkspaceMembership, arg.ID, arg.UserID) + var i GetWorkspaceMembershipRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.ReportRetentionDays, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.Role, + ) + return i, err +} + +const listUserWorkspaces = `-- name: ListUserWorkspaces :many +SELECT w.id, w.name, w.slug, w.report_retention_days, w.created_by, w.created_at, w.updated_at, wm.role FROM workspaces w +JOIN workspace_members wm ON wm.workspace_id = w.id +WHERE wm.user_id = $1 +ORDER BY w.created_at +` + +type ListUserWorkspacesRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + ReportRetentionDays int32 `json:"report_retention_days"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Role string `json:"role"` +} + +func (q *Queries) ListUserWorkspaces(ctx context.Context, userID uuid.UUID) ([]ListUserWorkspacesRow, error) { + rows, err := q.db.Query(ctx, listUserWorkspaces, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUserWorkspacesRow{} + for rows.Next() { + var i ListUserWorkspacesRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.ReportRetentionDays, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.Role, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listWorkspaceInvitations = `-- name: ListWorkspaceInvitations :many +SELECT id, workspace_id, email, token_hash, created_by, expires_at, accepted_at, revoked_at, created_at FROM workspace_invitations +WHERE workspace_id = $1 + AND ( + $2::timestamptz IS NULL + OR (created_at, id) < ($2, $3::uuid) + ) +ORDER BY created_at DESC, id DESC +LIMIT $4 +` + +type ListWorkspaceInvitationsParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListWorkspaceInvitations(ctx context.Context, arg ListWorkspaceInvitationsParams) ([]WorkspaceInvitation, error) { + rows, err := q.db.Query(ctx, listWorkspaceInvitations, + arg.WorkspaceID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []WorkspaceInvitation{} + for rows.Next() { + var i WorkspaceInvitation + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Email, + &i.TokenHash, + &i.CreatedBy, + &i.ExpiresAt, + &i.AcceptedAt, + &i.RevokedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listWorkspaceMembers = `-- name: ListWorkspaceMembers :many +SELECT u.id, u.username, u.email, u.display_name, wm.role, wm.created_at +FROM workspace_members wm JOIN users u ON u.id = wm.user_id +WHERE wm.workspace_id = $1 + AND ( + $2::timestamptz IS NULL + OR (wm.created_at, wm.user_id) < ($2, $3::uuid) + ) +ORDER BY wm.created_at DESC, wm.user_id DESC +LIMIT $4 +` + +type ListWorkspaceMembersParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +type ListWorkspaceMembersRow struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) ListWorkspaceMembers(ctx context.Context, arg ListWorkspaceMembersParams) ([]ListWorkspaceMembersRow, error) { + rows, err := q.db.Query(ctx, listWorkspaceMembers, + arg.WorkspaceID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListWorkspaceMembersRow{} + for rows.Next() { + var i ListWorkspaceMembersRow + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Email, + &i.DisplayName, + &i.Role, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockWorkspaceQuota = `-- name: LockWorkspaceQuota :one +SELECT id FROM workspaces WHERE id=$1 FOR UPDATE +` + +func (q *Queries) LockWorkspaceQuota(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, lockWorkspaceQuota, id) + err := row.Scan(&id) + return id, err +} + +const removeWorkspaceMember = `-- name: RemoveWorkspaceMember :execrows +DELETE FROM workspace_members WHERE workspace_id = $1 AND user_id = $2 AND role <> 'owner' +` + +type RemoveWorkspaceMemberParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) RemoveWorkspaceMember(ctx context.Context, arg RemoveWorkspaceMemberParams) (int64, error) { + result, err := q.db.Exec(ctx, removeWorkspaceMember, arg.WorkspaceID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const revokeWorkspaceInvitation = `-- name: RevokeWorkspaceInvitation :execrows +UPDATE workspace_invitations SET revoked_at = now() +WHERE id = $1 AND workspace_id = $2 AND accepted_at IS NULL AND revoked_at IS NULL +` + +type RevokeWorkspaceInvitationParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) RevokeWorkspaceInvitation(ctx context.Context, arg RevokeWorkspaceInvitationParams) (int64, error) { + result, err := q.db.Exec(ctx, revokeWorkspaceInvitation, arg.ID, arg.WorkspaceID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const updateUserProfile = `-- name: UpdateUserProfile :one +UPDATE users SET display_name = $2, username = $3, email = $4, updated_at = now() +WHERE id = $1 RETURNING id, display_name, username, email, status, accepted_terms_at, accepted_privacy_at, created_at, updated_at +` + +type UpdateUserProfileParams struct { + ID uuid.UUID `json:"id"` + DisplayName string `json:"display_name"` + Username string `json:"username"` + Email string `json:"email"` +} + +func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserProfile, + arg.ID, + arg.DisplayName, + arg.Username, + arg.Email, + ) + var i User + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.Username, + &i.Email, + &i.Status, + &i.AcceptedTermsAt, + &i.AcceptedPrivacyAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateWorkspace = `-- name: UpdateWorkspace :one +UPDATE workspaces +SET name = COALESCE($1::text, name), + report_retention_days = COALESCE($2::integer, report_retention_days), + updated_at = clock_timestamp() +WHERE id = $3 +RETURNING id, name, slug, report_retention_days, created_by, created_at, updated_at +` + +type UpdateWorkspaceParams struct { + Name pgtype.Text `json:"name"` + ReportRetentionDays pgtype.Int4 `json:"report_retention_days"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) { + row := q.db.QueryRow(ctx, updateWorkspace, arg.Name, arg.ReportRetentionDays, arg.ID) + var i Workspace + err := row.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.ReportRetentionDays, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/data/sqlc/models.go b/internal/data/sqlc/models.go new file mode 100644 index 0000000..6d15bcd --- /dev/null +++ b/internal/data/sqlc/models.go @@ -0,0 +1,368 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package sqlc + +import ( + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type AnalysisProfile struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Name string `json:"name"` + CurrentVersion int32 `json:"current_version"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type AnalysisProfileVersion struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ProfileID uuid.UUID `json:"profile_id"` + Version int32 `json:"version"` + DimensionKey string `json:"dimension_key"` + Definition []byte `json:"definition"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AnalysisReport struct { + ID uuid.UUID `json:"id"` + AnalysisRunID uuid.UUID `json:"analysis_run_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + RepositoryID uuid.UUID `json:"repository_id"` + SnapshotID uuid.UUID `json:"snapshot_id"` + CommitSha string `json:"commit_sha"` + SourceRef string `json:"source_ref"` + CommitAuthorName pgtype.Text `json:"commit_author_name"` + CommitAuthoredAt pgtype.Timestamptz `json:"commit_authored_at"` + CommitTitle pgtype.Text `json:"commit_title"` + DimensionKey string `json:"dimension_key"` + ProfileID uuid.UUID `json:"profile_id"` + ProfileVersion string `json:"profile_version"` + ProfileSnapshot []byte `json:"profile_snapshot"` + AnalyzerVersion string `json:"analyzer_version"` + ExecutionEnvironment string `json:"execution_environment"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + DurationMs int64 `json:"duration_ms"` + Result []byte `json:"result"` + RawArtifact []byte `json:"raw_artifact"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AnalysisRun struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + RepositoryID uuid.UUID `json:"repository_id"` + SnapshotID uuid.UUID `json:"snapshot_id"` + CommitSha string `json:"commit_sha"` + RequestedBy uuid.UUID `json:"requested_by"` + DimensionKey string `json:"dimension_key"` + ProfileID uuid.UUID `json:"profile_id"` + ProfileVersion string `json:"profile_version"` + ProfileSnapshot []byte `json:"profile_snapshot"` + AnalyzerVersion string `json:"analyzer_version"` + IdempotencyKey string `json:"idempotency_key"` + Attempt int32 `json:"attempt"` + RerunOf uuid.NullUUID `json:"rerun_of"` + Status string `json:"status"` + Stage string `json:"stage"` + ReportID uuid.NullUUID `json:"report_id"` + WorkflowRunID pgtype.Text `json:"workflow_run_id"` + FailedStage pgtype.Text `json:"failed_stage"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` + Retryable bool `json:"retryable"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` +} + +type AuditEvent struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.NullUUID `json:"workspace_id"` + ActorUserID uuid.NullUUID `json:"actor_user_id"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID uuid.NullUUID `json:"resource_id"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Channel struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Type string `json:"type"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + RuntimeStatus string `json:"runtime_status"` + SecretCiphertext []byte `json:"secret_ciphertext"` + SecretNonce []byte `json:"secret_nonce"` + KeyVersion pgtype.Int4 `json:"key_version"` + ConfigVersion int64 `json:"config_version"` + Config []byte `json:"config"` + LastConnectedAt pgtype.Timestamptz `json:"last_connected_at"` + LastErrorMessage pgtype.Text `json:"last_error_message"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ChannelExternalIdentity struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + SenderCanonicalID string `json:"sender_canonical_id"` + UserID uuid.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ChannelIdentityLink struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + ChannelVersion int64 `json:"channel_version"` + SenderCanonicalID string `json:"sender_canonical_id"` + TokenHash []byte `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + ConsumedAt pgtype.Timestamptz `json:"consumed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ChannelSubscription struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + EventType string `json:"event_type"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSnapshot struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` + CommitSha string `json:"commit_sha"` + SourceRef string `json:"source_ref"` + GitRef string `json:"git_ref"` + AuthorName pgtype.Text `json:"author_name"` + AuthoredAt pgtype.Timestamptz `json:"authored_at"` + Title pgtype.Text `json:"title"` + SourceState string `json:"source_state"` + PurgeCleanupID uuid.NullUUID `json:"purge_cleanup_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Conversation struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + ExternalID string `json:"external_id"` + Type string `json:"type"` + Title string `json:"title"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ConversationBinding struct { + ConversationID uuid.UUID `json:"conversation_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + RepositoryID uuid.UUID `json:"repository_id"` + BoundBy uuid.UUID `json:"bound_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Message struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ChannelID uuid.UUID `json:"channel_id"` + ConversationID uuid.UUID `json:"conversation_id"` + ExternalID string `json:"external_id"` + SenderCanonicalID string `json:"sender_canonical_id"` + SenderDisplayName string `json:"sender_display_name"` + Content []byte `json:"content"` + OccurredAt pgtype.Timestamptz `json:"occurred_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Notification struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + AnalysisRunID uuid.UUID `json:"analysis_run_id"` + ChannelID uuid.UUID `json:"channel_id"` + EventType string `json:"event_type"` + Status string `json:"status"` + WorkflowRunID pgtype.Text `json:"workflow_run_id"` + ErrorMessage pgtype.Text `json:"error_message"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` +} + +type OauthIdentity struct { + Issuer string `json:"issuer"` + Subject string `json:"subject"` + UserID uuid.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ProviderConnection struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + ProviderType string `json:"provider_type"` + BaseUrl string `json:"base_url"` + TokenCiphertext []byte `json:"token_ciphertext"` + TokenNonce []byte `json:"token_nonce"` + KeyVersion int32 `json:"key_version"` + CredentialVersion int64 `json:"credential_version"` + ProviderAccountID pgtype.Text `json:"provider_account_id"` + Login pgtype.Text `json:"login"` + DisplayName pgtype.Text `json:"display_name"` + Scopes []string `json:"scopes"` + IsDefault bool `json:"is_default"` + Status string `json:"status"` + LastValidatedAt pgtype.Timestamptz `json:"last_validated_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + LastErrorCode pgtype.Text `json:"last_error_code"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Repository struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ProviderType string `json:"provider_type"` + Name string `json:"name"` + RemoteUrl string `json:"remote_url"` + NormalizedUrl string `json:"normalized_url"` + ConfiguredRef string `json:"configured_ref"` + ConfigVersion int64 `json:"config_version"` + GitPath string `json:"git_path"` + Status string `json:"status"` + CurrentSnapshotID uuid.NullUUID `json:"current_snapshot_id"` + MirrorSizeBytes int64 `json:"mirror_size_bytes"` + LastSyncAt pgtype.Timestamptz `json:"last_sync_at"` + LastErrorCode pgtype.Text `json:"last_error_code"` + LastErrorMessage pgtype.Text `json:"last_error_message"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type RepositoryOperation struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` + ActorUserID uuid.UUID `json:"actor_user_id"` + ProviderConnectionID uuid.NullUUID `json:"provider_connection_id"` + CredentialVersion pgtype.Int8 `json:"credential_version"` + RepositoryVersion int64 `json:"repository_version"` + Kind string `json:"kind"` + RequestedProviderType string `json:"requested_provider_type"` + RequestedRemoteUrl string `json:"requested_remote_url"` + RequestedNormalizedUrl string `json:"requested_normalized_url"` + RequestedRef string `json:"requested_ref"` + PreviousProviderType string `json:"previous_provider_type"` + PreviousRemoteUrl string `json:"previous_remote_url"` + PreviousNormalizedUrl string `json:"previous_normalized_url"` + PreviousRef string `json:"previous_ref"` + Status string `json:"status"` + Outcome pgtype.Text `json:"outcome"` + ResolvedCommitSha pgtype.Text `json:"resolved_commit_sha"` + SnapshotID uuid.NullUUID `json:"snapshot_id"` + WorkflowRunID pgtype.Text `json:"workflow_run_id"` + ErrorMessage pgtype.Text `json:"error_message"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` +} + +type RepositorySourceKey struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + NormalizedUrl string `json:"normalized_url"` + RepositoryID uuid.UUID `json:"repository_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type RetentionCleanup struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ScheduledFor pgtype.Timestamptz `json:"scheduled_for"` + RetentionDays int32 `json:"retention_days"` + Status string `json:"status"` + WorkflowRunID pgtype.Text `json:"workflow_run_id"` + DeletedRunCount int32 `json:"deleted_run_count"` + PurgedSnapshotCount int32 `json:"purged_snapshot_count"` + RequeuedRepositoryCount int32 `json:"requeued_repository_count"` + ErrorMessage pgtype.Text `json:"error_message"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` +} + +type RetentionCleanupSchedule struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + LastRunAt pgtype.Timestamptz `json:"last_run_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type User struct { + ID uuid.UUID `json:"id"` + DisplayName string `json:"display_name"` + Username string `json:"username"` + Email string `json:"email"` + Status string `json:"status"` + AcceptedTermsAt pgtype.Timestamptz `json:"accepted_terms_at"` + AcceptedPrivacyAt pgtype.Timestamptz `json:"accepted_privacy_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type WorkflowDispatch struct { + ID uuid.UUID `json:"id"` + AggregateType string `json:"aggregate_type"` + AggregateID uuid.UUID `json:"aggregate_id"` + WorkflowName string `json:"workflow_name"` + Payload []byte `json:"payload"` + Status string `json:"status"` + Attempts int32 `json:"attempts"` + AvailableAt pgtype.Timestamptz `json:"available_at"` + LastError pgtype.Text `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + DispatchedAt pgtype.Timestamptz `json:"dispatched_at"` +} + +type Workspace struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + ReportRetentionDays int32 `json:"report_retention_days"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type WorkspaceInvitation struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Email string `json:"email"` + TokenHash []byte `json:"token_hash"` + CreatedBy uuid.UUID `json:"created_by"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + AcceptedAt pgtype.Timestamptz `json:"accepted_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type WorkspaceMember struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + UserID uuid.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} diff --git a/internal/data/sqlc/notifications.sql.go b/internal/data/sqlc/notifications.sql.go new file mode 100644 index 0000000..aab1555 --- /dev/null +++ b/internal/data/sqlc/notifications.sql.go @@ -0,0 +1,160 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: notifications.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const cancelChannelNotifications = `-- name: CancelChannelNotifications :exec +WITH cancelled_notifications AS ( + UPDATE notifications + SET status='cancelled', finished_at=now() + WHERE channel_id=$1 AND workspace_id=$2 AND status IN ('queued','running') + RETURNING id +) +UPDATE workflow_dispatches AS dispatch +SET status='cancelled' +FROM cancelled_notifications AS notification +WHERE dispatch.aggregate_type='notification' + AND dispatch.aggregate_id=notification.id + AND dispatch.status='pending' +` + +type CancelChannelNotificationsParams struct { + ChannelID uuid.UUID `json:"channel_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) CancelChannelNotifications(ctx context.Context, arg CancelChannelNotificationsParams) error { + _, err := q.db.Exec(ctx, cancelChannelNotifications, arg.ChannelID, arg.WorkspaceID) + return err +} + +const cancelNotification = `-- name: CancelNotification :exec +UPDATE notifications SET status='cancelled', finished_at=now() +WHERE id=$1 AND status IN ('queued','running') +` + +func (q *Queries) CancelNotification(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, cancelNotification, id) + return err +} + +const createNotification = `-- name: CreateNotification :one +INSERT INTO notifications (id, workspace_id, analysis_run_id, channel_id, event_type, status) +VALUES ($1,$2,$3,$4,$5,'queued') RETURNING id, workspace_id, analysis_run_id, channel_id, event_type, status, workflow_run_id, error_message, created_at, started_at, finished_at +` + +type CreateNotificationParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + AnalysisRunID uuid.UUID `json:"analysis_run_id"` + ChannelID uuid.UUID `json:"channel_id"` + EventType string `json:"event_type"` +} + +func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error) { + row := q.db.QueryRow(ctx, createNotification, + arg.ID, + arg.WorkspaceID, + arg.AnalysisRunID, + arg.ChannelID, + arg.EventType, + ) + var i Notification + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.AnalysisRunID, + &i.ChannelID, + &i.EventType, + &i.Status, + &i.WorkflowRunID, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const failNotification = `-- name: FailNotification :exec +WITH failed AS ( + UPDATE notifications SET status='failed', error_message=$2, finished_at=now() + WHERE notifications.id=$1 AND notifications.status IN ('queued','running') RETURNING channel_id +) +UPDATE channels SET last_error_message=$2, updated_at=now() +FROM failed WHERE channels.id=failed.channel_id +` + +type FailNotificationParams struct { + ID uuid.UUID `json:"id"` + LastErrorMessage pgtype.Text `json:"last_error_message"` +} + +func (q *Queries) FailNotification(ctx context.Context, arg FailNotificationParams) error { + _, err := q.db.Exec(ctx, failNotification, arg.ID, arg.LastErrorMessage) + return err +} + +const finishNotification = `-- name: FinishNotification :execrows +WITH delivered AS ( + UPDATE notifications SET status='delivered', finished_at=now() + WHERE notifications.id=$1 AND notifications.status='running' RETURNING channel_id +) +UPDATE channels SET last_connected_at=now(), last_error_message=NULL, updated_at=now() +FROM delivered WHERE channels.id=delivered.channel_id +` + +func (q *Queries) FinishNotification(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.Exec(ctx, finishNotification, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setNotificationWorkflowID = `-- name: SetNotificationWorkflowID :exec +UPDATE notifications SET workflow_run_id=$2 WHERE id=$1 +` + +type SetNotificationWorkflowIDParams struct { + ID uuid.UUID `json:"id"` + WorkflowRunID pgtype.Text `json:"workflow_run_id"` +} + +func (q *Queries) SetNotificationWorkflowID(ctx context.Context, arg SetNotificationWorkflowIDParams) error { + _, err := q.db.Exec(ctx, setNotificationWorkflowID, arg.ID, arg.WorkflowRunID) + return err +} + +const startNotification = `-- name: StartNotification :one +UPDATE notifications SET status='running', started_at=now() +WHERE id=$1 AND status='queued' RETURNING id, workspace_id, analysis_run_id, channel_id, event_type, status, workflow_run_id, error_message, created_at, started_at, finished_at +` + +func (q *Queries) StartNotification(ctx context.Context, id uuid.UUID) (Notification, error) { + row := q.db.QueryRow(ctx, startNotification, id) + var i Notification + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.AnalysisRunID, + &i.ChannelID, + &i.EventType, + &i.Status, + &i.WorkflowRunID, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} diff --git a/internal/data/sqlc/providers.sql.go b/internal/data/sqlc/providers.sql.go new file mode 100644 index 0000000..9a407f1 --- /dev/null +++ b/internal/data/sqlc/providers.sql.go @@ -0,0 +1,388 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: providers.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const clearDefaultProviderConnections = `-- name: ClearDefaultProviderConnections :exec +UPDATE provider_connections SET is_default=false, updated_at=now() +WHERE user_id=$1 AND provider_type=$2 AND status <> 'revoked' +` + +type ClearDefaultProviderConnectionsParams struct { + UserID uuid.UUID `json:"user_id"` + ProviderType string `json:"provider_type"` +} + +func (q *Queries) ClearDefaultProviderConnections(ctx context.Context, arg ClearDefaultProviderConnectionsParams) error { + _, err := q.db.Exec(ctx, clearDefaultProviderConnections, arg.UserID, arg.ProviderType) + return err +} + +const createProviderConnection = `-- name: CreateProviderConnection :one +INSERT INTO provider_connections ( + id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, + provider_account_id, login, display_name, scopes, is_default, status, last_validated_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active',now()) +RETURNING id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at +` + +type CreateProviderConnectionParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + ProviderType string `json:"provider_type"` + BaseUrl string `json:"base_url"` + TokenCiphertext []byte `json:"token_ciphertext"` + TokenNonce []byte `json:"token_nonce"` + KeyVersion int32 `json:"key_version"` + ProviderAccountID pgtype.Text `json:"provider_account_id"` + Login pgtype.Text `json:"login"` + DisplayName pgtype.Text `json:"display_name"` + Scopes []string `json:"scopes"` + IsDefault bool `json:"is_default"` +} + +func (q *Queries) CreateProviderConnection(ctx context.Context, arg CreateProviderConnectionParams) (ProviderConnection, error) { + row := q.db.QueryRow(ctx, createProviderConnection, + arg.ID, + arg.UserID, + arg.ProviderType, + arg.BaseUrl, + arg.TokenCiphertext, + arg.TokenNonce, + arg.KeyVersion, + arg.ProviderAccountID, + arg.Login, + arg.DisplayName, + arg.Scopes, + arg.IsDefault, + ) + var i ProviderConnection + err := row.Scan( + &i.ID, + &i.UserID, + &i.ProviderType, + &i.BaseUrl, + &i.TokenCiphertext, + &i.TokenNonce, + &i.KeyVersion, + &i.CredentialVersion, + &i.ProviderAccountID, + &i.Login, + &i.DisplayName, + &i.Scopes, + &i.IsDefault, + &i.Status, + &i.LastValidatedAt, + &i.LastUsedAt, + &i.LastErrorCode, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getProviderConnection = `-- name: GetProviderConnection :one +SELECT id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at FROM provider_connections WHERE id = $1 AND user_id = $2 AND status <> 'revoked' +` + +type GetProviderConnectionParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) GetProviderConnection(ctx context.Context, arg GetProviderConnectionParams) (ProviderConnection, error) { + row := q.db.QueryRow(ctx, getProviderConnection, arg.ID, arg.UserID) + var i ProviderConnection + err := row.Scan( + &i.ID, + &i.UserID, + &i.ProviderType, + &i.BaseUrl, + &i.TokenCiphertext, + &i.TokenNonce, + &i.KeyVersion, + &i.CredentialVersion, + &i.ProviderAccountID, + &i.Login, + &i.DisplayName, + &i.Scopes, + &i.IsDefault, + &i.Status, + &i.LastValidatedAt, + &i.LastUsedAt, + &i.LastErrorCode, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listProviderConnections = `-- name: ListProviderConnections :many +SELECT id, user_id, provider_type, base_url, provider_account_id, login, display_name, + scopes, is_default, status, credential_version, last_validated_at, last_used_at, last_error_code, + created_at, updated_at +FROM provider_connections WHERE user_id = $1 AND status <> 'revoked' + AND ( + $2::timestamptz IS NULL + OR (created_at, id) < ($2, $3::uuid) + ) +ORDER BY created_at DESC, id DESC +LIMIT $4 +` + +type ListProviderConnectionsParams struct { + UserID uuid.UUID `json:"user_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +type ListProviderConnectionsRow struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + ProviderType string `json:"provider_type"` + BaseUrl string `json:"base_url"` + ProviderAccountID pgtype.Text `json:"provider_account_id"` + Login pgtype.Text `json:"login"` + DisplayName pgtype.Text `json:"display_name"` + Scopes []string `json:"scopes"` + IsDefault bool `json:"is_default"` + Status string `json:"status"` + CredentialVersion int64 `json:"credential_version"` + LastValidatedAt pgtype.Timestamptz `json:"last_validated_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + LastErrorCode pgtype.Text `json:"last_error_code"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListProviderConnections(ctx context.Context, arg ListProviderConnectionsParams) ([]ListProviderConnectionsRow, error) { + rows, err := q.db.Query(ctx, listProviderConnections, + arg.UserID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListProviderConnectionsRow{} + for rows.Next() { + var i ListProviderConnectionsRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.ProviderType, + &i.BaseUrl, + &i.ProviderAccountID, + &i.Login, + &i.DisplayName, + &i.Scopes, + &i.IsDefault, + &i.Status, + &i.CredentialVersion, + &i.LastValidatedAt, + &i.LastUsedAt, + &i.LastErrorCode, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markProviderConnectionUsed = `-- name: MarkProviderConnectionUsed :execrows +UPDATE provider_connections SET last_used_at=now(), updated_at=now() +WHERE id=$1 AND user_id=$2 AND status='active' +` + +type MarkProviderConnectionUsedParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) MarkProviderConnectionUsed(ctx context.Context, arg MarkProviderConnectionUsedParams) (int64, error) { + result, err := q.db.Exec(ctx, markProviderConnectionUsed, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const replaceProviderCredential = `-- name: ReplaceProviderCredential :one +UPDATE provider_connections +SET token_ciphertext=$3, token_nonce=$4, key_version=$5, + credential_version=credential_version+1, status='active', last_error_code=NULL, updated_at=now() +WHERE id=$1 AND user_id=$2 AND status <> 'revoked' +RETURNING id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at +` + +type ReplaceProviderCredentialParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenCiphertext []byte `json:"token_ciphertext"` + TokenNonce []byte `json:"token_nonce"` + KeyVersion int32 `json:"key_version"` +} + +func (q *Queries) ReplaceProviderCredential(ctx context.Context, arg ReplaceProviderCredentialParams) (ProviderConnection, error) { + row := q.db.QueryRow(ctx, replaceProviderCredential, + arg.ID, + arg.UserID, + arg.TokenCiphertext, + arg.TokenNonce, + arg.KeyVersion, + ) + var i ProviderConnection + err := row.Scan( + &i.ID, + &i.UserID, + &i.ProviderType, + &i.BaseUrl, + &i.TokenCiphertext, + &i.TokenNonce, + &i.KeyVersion, + &i.CredentialVersion, + &i.ProviderAccountID, + &i.Login, + &i.DisplayName, + &i.Scopes, + &i.IsDefault, + &i.Status, + &i.LastValidatedAt, + &i.LastUsedAt, + &i.LastErrorCode, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const revokeProviderConnection = `-- name: RevokeProviderConnection :execrows +UPDATE provider_connections +SET status='revoked', is_default=false, token_ciphertext=decode('', 'hex'), + token_nonce=decode('', 'hex'), credential_version=credential_version+1, updated_at=now() +WHERE id=$1 AND user_id=$2 AND status <> 'revoked' +` + +type RevokeProviderConnectionParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) RevokeProviderConnection(ctx context.Context, arg RevokeProviderConnectionParams) (int64, error) { + result, err := q.db.Exec(ctx, revokeProviderConnection, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setDefaultProviderConnection = `-- name: SetDefaultProviderConnection :one +UPDATE provider_connections SET is_default=true, updated_at=now() +WHERE id=$1 AND user_id=$2 AND status='active' +RETURNING id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at +` + +type SetDefaultProviderConnectionParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) SetDefaultProviderConnection(ctx context.Context, arg SetDefaultProviderConnectionParams) (ProviderConnection, error) { + row := q.db.QueryRow(ctx, setDefaultProviderConnection, arg.ID, arg.UserID) + var i ProviderConnection + err := row.Scan( + &i.ID, + &i.UserID, + &i.ProviderType, + &i.BaseUrl, + &i.TokenCiphertext, + &i.TokenNonce, + &i.KeyVersion, + &i.CredentialVersion, + &i.ProviderAccountID, + &i.Login, + &i.DisplayName, + &i.Scopes, + &i.IsDefault, + &i.Status, + &i.LastValidatedAt, + &i.LastUsedAt, + &i.LastErrorCode, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const setProviderConnectionValidation = `-- name: SetProviderConnectionValidation :one +UPDATE provider_connections +SET provider_account_id=$3, login=$4, display_name=$5, scopes=$6, status=$7, + last_validated_at=now(), last_error_code=$8, updated_at=now() +WHERE id=$1 AND user_id=$2 AND status <> 'revoked' +RETURNING id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at +` + +type SetProviderConnectionValidationParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + ProviderAccountID pgtype.Text `json:"provider_account_id"` + Login pgtype.Text `json:"login"` + DisplayName pgtype.Text `json:"display_name"` + Scopes []string `json:"scopes"` + Status string `json:"status"` + LastErrorCode pgtype.Text `json:"last_error_code"` +} + +func (q *Queries) SetProviderConnectionValidation(ctx context.Context, arg SetProviderConnectionValidationParams) (ProviderConnection, error) { + row := q.db.QueryRow(ctx, setProviderConnectionValidation, + arg.ID, + arg.UserID, + arg.ProviderAccountID, + arg.Login, + arg.DisplayName, + arg.Scopes, + arg.Status, + arg.LastErrorCode, + ) + var i ProviderConnection + err := row.Scan( + &i.ID, + &i.UserID, + &i.ProviderType, + &i.BaseUrl, + &i.TokenCiphertext, + &i.TokenNonce, + &i.KeyVersion, + &i.CredentialVersion, + &i.ProviderAccountID, + &i.Login, + &i.DisplayName, + &i.Scopes, + &i.IsDefault, + &i.Status, + &i.LastValidatedAt, + &i.LastUsedAt, + &i.LastErrorCode, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/data/sqlc/querier.go b/internal/data/sqlc/querier.go new file mode 100644 index 0000000..0da32a5 --- /dev/null +++ b/internal/data/sqlc/querier.go @@ -0,0 +1,164 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type Querier interface { + AcceptWorkspaceInvitation(ctx context.Context, id uuid.UUID) (int64, error) + ActivateUser(ctx context.Context, id uuid.UUID) (User, error) + AddRequeuedRepositoryCount(ctx context.Context, arg AddRequeuedRepositoryCountParams) (int64, error) + AddRetentionCleanupProgress(ctx context.Context, arg AddRetentionCleanupProgressParams) (int64, error) + AddWorkspaceMember(ctx context.Context, arg AddWorkspaceMemberParams) error + AdvanceAnalysisProfile(ctx context.Context, arg AdvanceAnalysisProfileParams) (AnalysisProfile, error) + AdvanceRetentionSchedule(ctx context.Context, arg AdvanceRetentionScheduleParams) (int64, error) + AnalysisClock(ctx context.Context) (pgtype.Timestamptz, error) + ArchiveAnalysisProfile(ctx context.Context, arg ArchiveAnalysisProfileParams) (int64, error) + ArchiveRepository(ctx context.Context, arg ArchiveRepositoryParams) (Repository, error) + CancelAnalysisRun(ctx context.Context, arg CancelAnalysisRunParams) (pgtype.Text, error) + CancelChannelNotifications(ctx context.Context, arg CancelChannelNotificationsParams) error + CancelNotification(ctx context.Context, id uuid.UUID) error + CancelRepositoryOperation(ctx context.Context, arg CancelRepositoryOperationParams) (RepositoryOperation, error) + CancelWorkflowDispatch(ctx context.Context, arg CancelWorkflowDispatchParams) error + CheckActiveChannelVersion(ctx context.Context, arg CheckActiveChannelVersionParams) (int64, error) + ClaimDueRetentionSchedules(ctx context.Context, limit int32) ([]ClaimDueRetentionSchedulesRow, error) + ClaimRepositoriesReadyForPurge(ctx context.Context, arg ClaimRepositoriesReadyForPurgeParams) ([]Repository, error) + ClaimWorkflowDispatches(ctx context.Context, arg ClaimWorkflowDispatchesParams) ([]WorkflowDispatch, error) + ClearDefaultProviderConnections(ctx context.Context, arg ClearDefaultProviderConnectionsParams) error + CompleteRepositoryLogicalDeletion(ctx context.Context, arg CompleteRepositoryLogicalDeletionParams) (int64, error) + CompleteSnapshotPurge(ctx context.Context, arg CompleteSnapshotPurgeParams) (int64, error) + ConsumeChannelIdentityLink(ctx context.Context, id uuid.UUID) (int64, error) + CountActiveAnalysisRuns(ctx context.Context, workspaceID uuid.UUID) (int64, error) + CountManagedRepositories(ctx context.Context, workspaceID uuid.UUID) (int64, error) + CreateAnalysisProfile(ctx context.Context, arg CreateAnalysisProfileParams) (AnalysisProfile, error) + CreateAnalysisProfileVersion(ctx context.Context, arg CreateAnalysisProfileVersionParams) (AnalysisProfileVersion, error) + CreateAnalysisReport(ctx context.Context, arg CreateAnalysisReportParams) (AnalysisReport, error) + CreateAnalysisRun(ctx context.Context, arg CreateAnalysisRunParams) (AnalysisRun, error) + CreateAuditEvent(ctx context.Context, arg CreateAuditEventParams) error + CreateChannel(ctx context.Context, arg CreateChannelParams) (Channel, error) + CreateChannelIdentityLink(ctx context.Context, arg CreateChannelIdentityLinkParams) (ChannelIdentityLink, error) + CreateChannelSubscription(ctx context.Context, arg CreateChannelSubscriptionParams) error + CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error) + CreateOAuthIdentity(ctx context.Context, arg CreateOAuthIdentityParams) error + CreateProviderConnection(ctx context.Context, arg CreateProviderConnectionParams) (ProviderConnection, error) + CreateRepository(ctx context.Context, arg CreateRepositoryParams) (Repository, error) + CreateRepositoryOperation(ctx context.Context, arg CreateRepositoryOperationParams) (RepositoryOperation, error) + CreateRetentionCleanup(ctx context.Context, arg CreateRetentionCleanupParams) (RetentionCleanup, error) + CreateRetentionCleanupSchedule(ctx context.Context, workspaceID uuid.UUID) error + CreateUser(ctx context.Context, arg CreateUserParams) (User, error) + CreateWorkflowDispatch(ctx context.Context, arg CreateWorkflowDispatchParams) (WorkflowDispatch, error) + CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) + CreateWorkspaceInvitation(ctx context.Context, arg CreateWorkspaceInvitationParams) (WorkspaceInvitation, error) + DelayWorkflowDispatch(ctx context.Context, arg DelayWorkflowDispatchParams) error + DeleteChannel(ctx context.Context, arg DeleteChannelParams) (int64, error) + DeleteChannelSubscriptions(ctx context.Context, arg DeleteChannelSubscriptionsParams) error + DeleteConversationBinding(ctx context.Context, conversationID uuid.UUID) (int64, error) + DeleteExpiredAnalysisRuns(ctx context.Context, arg DeleteExpiredAnalysisRunsParams) ([]uuid.UUID, error) + DeleteRepository(ctx context.Context, arg DeleteRepositoryParams) (int64, error) + EnsureSnapshot(ctx context.Context, arg EnsureSnapshotParams) (CommitSnapshot, error) + FailAnalysisRun(ctx context.Context, arg FailAnalysisRunParams) (AnalysisRun, error) + FailNotification(ctx context.Context, arg FailNotificationParams) error + FailRepositoryOperation(ctx context.Context, arg FailRepositoryOperationParams) (RepositoryOperation, error) + FailRetentionCleanup(ctx context.Context, arg FailRetentionCleanupParams) (int64, error) + FinishAnalysisRun(ctx context.Context, arg FinishAnalysisRunParams) (AnalysisRun, error) + FinishNotification(ctx context.Context, id uuid.UUID) (int64, error) + FinishRepositoryOperation(ctx context.Context, arg FinishRepositoryOperationParams) (RepositoryOperation, error) + FinishRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error) + GetAnalysisProfile(ctx context.Context, arg GetAnalysisProfileParams) (GetAnalysisProfileRow, error) + GetAnalysisReport(ctx context.Context, arg GetAnalysisReportParams) (AnalysisReport, error) + GetAnalysisReportByRun(ctx context.Context, analysisRunID uuid.UUID) (AnalysisReport, error) + GetAnalysisRun(ctx context.Context, arg GetAnalysisRunParams) (AnalysisRun, error) + GetAnalysisRunByAttempt(ctx context.Context, arg GetAnalysisRunByAttemptParams) (AnalysisRun, error) + GetAnalysisRunWork(ctx context.Context, id uuid.UUID) (AnalysisRun, error) + GetChannel(ctx context.Context, arg GetChannelParams) (Channel, error) + GetChannelExternalIdentity(ctx context.Context, arg GetChannelExternalIdentityParams) (uuid.UUID, error) + GetChannelIdentityLink(ctx context.Context, tokenHash []byte) (ChannelIdentityLink, error) + GetConversationBinding(ctx context.Context, conversationID uuid.UUID) (uuid.UUID, error) + GetConversationIDByExternal(ctx context.Context, arg GetConversationIDByExternalParams) (uuid.UUID, error) + GetDefaultAnalysisProfile(ctx context.Context, workspaceID uuid.UUID) (GetDefaultAnalysisProfileRow, error) + GetInvitationByTokenHash(ctx context.Context, tokenHash []byte) (WorkspaceInvitation, error) + GetProviderConnection(ctx context.Context, arg GetProviderConnectionParams) (ProviderConnection, error) + GetRepository(ctx context.Context, arg GetRepositoryParams) (Repository, error) + GetRepositoryOperation(ctx context.Context, id uuid.UUID) (RepositoryOperation, error) + GetRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error) + GetSnapshot(ctx context.Context, arg GetSnapshotParams) (CommitSnapshot, error) + GetUser(ctx context.Context, id uuid.UUID) (User, error) + GetUserByIdentity(ctx context.Context, arg GetUserByIdentityParams) (User, error) + GetWorkspaceMembership(ctx context.Context, arg GetWorkspaceMembershipParams) (GetWorkspaceMembershipRow, error) + GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (GetWorkspaceOverviewRow, error) + GetWorkspaceSnapshot(ctx context.Context, arg GetWorkspaceSnapshotParams) (CommitSnapshot, error) + HasRepositoryAnalysisRuns(ctx context.Context, repositoryID uuid.UUID) (bool, error) + InsertInboundMessage(ctx context.Context, arg InsertInboundMessageParams) (int64, error) + IsWorkflowDispatchPending(ctx context.Context, id uuid.UUID) (bool, error) + ListAnalysisProfiles(ctx context.Context, arg ListAnalysisProfilesParams) ([]ListAnalysisProfilesRow, error) + ListAnalysisRuns(ctx context.Context, arg ListAnalysisRunsParams) ([]AnalysisRun, error) + ListChannelSubscriptions(ctx context.Context, workspaceID uuid.UUID) ([]ListChannelSubscriptionsRow, error) + ListChannels(ctx context.Context, arg ListChannelsParams) ([]Channel, error) + ListConversations(ctx context.Context, arg ListConversationsParams) ([]ListConversationsRow, error) + ListMessages(ctx context.Context, arg ListMessagesParams) ([]ListMessagesRow, error) + ListProviderConnections(ctx context.Context, arg ListProviderConnectionsParams) ([]ListProviderConnectionsRow, error) + ListRepositories(ctx context.Context, arg ListRepositoriesParams) ([]ListRepositoriesRow, error) + ListRepositoryOperations(ctx context.Context, arg ListRepositoryOperationsParams) ([]RepositoryOperation, error) + ListRepositorySnapshots(ctx context.Context, arg ListRepositorySnapshotsParams) ([]CommitSnapshot, error) + ListRuntimeChannels(ctx context.Context) ([]Channel, error) + ListSubscribedChannels(ctx context.Context, arg ListSubscribedChannelsParams) ([]Channel, error) + ListUserWorkspaces(ctx context.Context, userID uuid.UUID) ([]ListUserWorkspacesRow, error) + ListWorkspaceInvitations(ctx context.Context, arg ListWorkspaceInvitationsParams) ([]WorkspaceInvitation, error) + ListWorkspaceMembers(ctx context.Context, arg ListWorkspaceMembersParams) ([]ListWorkspaceMembersRow, error) + LockActiveAnalysisProfiles(ctx context.Context, workspaceID uuid.UUID) ([]uuid.UUID, error) + LockAnalysisSnapshot(ctx context.Context, arg LockAnalysisSnapshotParams) (string, error) + LockChannelRuntime(ctx context.Context, arg LockChannelRuntimeParams) (int64, error) + LockPurgeableSnapshotCandidates(ctx context.Context, arg LockPurgeableSnapshotCandidatesParams) ([]uuid.UUID, error) + LockWorkspaceQuota(ctx context.Context, id uuid.UUID) (uuid.UUID, error) + MarkChannelRuntimeConnected(ctx context.Context, arg MarkChannelRuntimeConnectedParams) (int64, error) + MarkChannelRuntimeError(ctx context.Context, arg MarkChannelRuntimeErrorParams) (int64, error) + MarkProviderConnectionUsed(ctx context.Context, arg MarkProviderConnectionUsedParams) (int64, error) + MarkSnapshotsPurged(ctx context.Context, repositoryID uuid.UUID) error + MarkSnapshotsPurging(ctx context.Context, arg MarkSnapshotsPurgingParams) ([]MarkSnapshotsPurgingRow, error) + MarkWorkflowDispatched(ctx context.Context, id uuid.UUID) (int64, error) + ProjectRepositoryOperationFailure(ctx context.Context, arg ProjectRepositoryOperationFailureParams) (int64, error) + ReactivateRepositoryPurge(ctx context.Context, id uuid.UUID) (int64, error) + ReleaseRepositorySource(ctx context.Context, arg ReleaseRepositorySourceParams) (int64, error) + RemoveWorkspaceMember(ctx context.Context, arg RemoveWorkspaceMemberParams) (int64, error) + ReplaceProviderCredential(ctx context.Context, arg ReplaceProviderCredentialParams) (ProviderConnection, error) + RequestRepositoryDeletion(ctx context.Context, arg RequestRepositoryDeletionParams) (Repository, error) + ReserveRepositorySource(ctx context.Context, arg ReserveRepositorySourceParams) (uuid.UUID, error) + RestoreDeletedRepositoryAfterPurgeFailure(ctx context.Context, arg RestoreDeletedRepositoryAfterPurgeFailureParams) (int64, error) + RestoreRepository(ctx context.Context, arg RestoreRepositoryParams) (Repository, error) + RestoreRepositoryAfterOperation(ctx context.Context, arg RestoreRepositoryAfterOperationParams) (int64, error) + RevokeProviderConnection(ctx context.Context, arg RevokeProviderConnectionParams) (int64, error) + RevokeWorkspaceInvitation(ctx context.Context, arg RevokeWorkspaceInvitationParams) (int64, error) + RotateChannelCredential(ctx context.Context, arg RotateChannelCredentialParams) (Channel, error) + SetAnalysisRunStage(ctx context.Context, arg SetAnalysisRunStageParams) (int64, error) + SetAnalysisWorkflowID(ctx context.Context, arg SetAnalysisWorkflowIDParams) error + SetChannelEnabled(ctx context.Context, arg SetChannelEnabledParams) (Channel, error) + SetDefaultProviderConnection(ctx context.Context, arg SetDefaultProviderConnectionParams) (ProviderConnection, error) + SetNotificationWorkflowID(ctx context.Context, arg SetNotificationWorkflowIDParams) error + SetProviderConnectionValidation(ctx context.Context, arg SetProviderConnectionValidationParams) (ProviderConnection, error) + SetRepositoryCurrentSnapshot(ctx context.Context, arg SetRepositoryCurrentSnapshotParams) (Repository, error) + SetRepositoryOperationWorkflowID(ctx context.Context, arg SetRepositoryOperationWorkflowIDParams) error + SetRepositoryStatus(ctx context.Context, arg SetRepositoryStatusParams) error + SetRepositorySyncing(ctx context.Context, arg SetRepositorySyncingParams) (int64, error) + SetRetentionCleanupWorkflowID(ctx context.Context, arg SetRetentionCleanupWorkflowIDParams) error + StageRepositoryUpdate(ctx context.Context, arg StageRepositoryUpdateParams) (Repository, error) + StartAnalysisRun(ctx context.Context, id uuid.UUID) (AnalysisRun, error) + StartNotification(ctx context.Context, id uuid.UUID) (Notification, error) + StartRepositoryOperation(ctx context.Context, id uuid.UUID) (RepositoryOperation, error) + StartRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error) + UpdateChannel(ctx context.Context, arg UpdateChannelParams) (Channel, error) + UpdateRepository(ctx context.Context, arg UpdateRepositoryParams) (Repository, error) + UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) + UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) + UpsertChannelExternalIdentity(ctx context.Context, arg UpsertChannelExternalIdentityParams) error + UpsertConversation(ctx context.Context, arg UpsertConversationParams) (uuid.UUID, error) + UpsertConversationBinding(ctx context.Context, arg UpsertConversationBindingParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/data/sqlc/repositories.sql.go b/internal/data/sqlc/repositories.sql.go new file mode 100644 index 0000000..f55ff90 --- /dev/null +++ b/internal/data/sqlc/repositories.sql.go @@ -0,0 +1,1264 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: repositories.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const archiveRepository = `-- name: ArchiveRepository :one +UPDATE repositories SET archived_at=now(), updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND archived_at IS NULL RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at +` + +type ArchiveRepositoryParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) ArchiveRepository(ctx context.Context, arg ArchiveRepositoryParams) (Repository, error) { + row := q.db.QueryRow(ctx, archiveRepository, arg.ID, arg.WorkspaceID) + var i Repository + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const cancelRepositoryOperation = `-- name: CancelRepositoryOperation :one +UPDATE repository_operations SET status='cancelled', finished_at=now() +WHERE id=$1 AND repository_id=$2 AND status IN ('queued','running') +RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at +` + +type CancelRepositoryOperationParams struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` +} + +func (q *Queries) CancelRepositoryOperation(ctx context.Context, arg CancelRepositoryOperationParams) (RepositoryOperation, error) { + row := q.db.QueryRow(ctx, cancelRepositoryOperation, arg.ID, arg.RepositoryID) + var i RepositoryOperation + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.ActorUserID, + &i.ProviderConnectionID, + &i.CredentialVersion, + &i.RepositoryVersion, + &i.Kind, + &i.RequestedProviderType, + &i.RequestedRemoteUrl, + &i.RequestedNormalizedUrl, + &i.RequestedRef, + &i.PreviousProviderType, + &i.PreviousRemoteUrl, + &i.PreviousNormalizedUrl, + &i.PreviousRef, + &i.Status, + &i.Outcome, + &i.ResolvedCommitSha, + &i.SnapshotID, + &i.WorkflowRunID, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const completeRepositoryLogicalDeletion = `-- name: CompleteRepositoryLogicalDeletion :execrows +WITH completed AS ( + UPDATE repository_operations + SET status='succeeded', outcome='no_change', finished_at=now() + WHERE repository_operations.id=$1 AND repository_operations.repository_id=$2 AND repository_operations.status='running' + RETURNING repository_operations.repository_id +) +UPDATE repositories +SET status='deleted', archived_at=COALESCE(archived_at, now()), deleted_at=now(), updated_at=now() +FROM completed +WHERE repositories.id=completed.repository_id +` + +type CompleteRepositoryLogicalDeletionParams struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` +} + +func (q *Queries) CompleteRepositoryLogicalDeletion(ctx context.Context, arg CompleteRepositoryLogicalDeletionParams) (int64, error) { + result, err := q.db.Exec(ctx, completeRepositoryLogicalDeletion, arg.ID, arg.RepositoryID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const countManagedRepositories = `-- name: CountManagedRepositories :one +SELECT count(*) FROM repositories WHERE workspace_id=$1 AND status <> 'deleted' +` + +func (q *Queries) CountManagedRepositories(ctx context.Context, workspaceID uuid.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countManagedRepositories, workspaceID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createRepository = `-- name: CreateRepository :one +INSERT INTO repositories (id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, git_path, status, created_by) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'provisioning',$9) RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at +` + +type CreateRepositoryParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ProviderType string `json:"provider_type"` + Name string `json:"name"` + RemoteUrl string `json:"remote_url"` + NormalizedUrl string `json:"normalized_url"` + ConfiguredRef string `json:"configured_ref"` + GitPath string `json:"git_path"` + CreatedBy uuid.UUID `json:"created_by"` +} + +func (q *Queries) CreateRepository(ctx context.Context, arg CreateRepositoryParams) (Repository, error) { + row := q.db.QueryRow(ctx, createRepository, + arg.ID, + arg.WorkspaceID, + arg.ProviderType, + arg.Name, + arg.RemoteUrl, + arg.NormalizedUrl, + arg.ConfiguredRef, + arg.GitPath, + arg.CreatedBy, + ) + var i Repository + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createRepositoryOperation = `-- name: CreateRepositoryOperation :one +INSERT INTO repository_operations ( + id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, + requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, + previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'queued') RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at +` + +type CreateRepositoryOperationParams struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` + ActorUserID uuid.UUID `json:"actor_user_id"` + ProviderConnectionID uuid.NullUUID `json:"provider_connection_id"` + CredentialVersion pgtype.Int8 `json:"credential_version"` + RepositoryVersion int64 `json:"repository_version"` + Kind string `json:"kind"` + RequestedProviderType string `json:"requested_provider_type"` + RequestedRemoteUrl string `json:"requested_remote_url"` + RequestedNormalizedUrl string `json:"requested_normalized_url"` + RequestedRef string `json:"requested_ref"` + PreviousProviderType string `json:"previous_provider_type"` + PreviousRemoteUrl string `json:"previous_remote_url"` + PreviousNormalizedUrl string `json:"previous_normalized_url"` + PreviousRef string `json:"previous_ref"` +} + +func (q *Queries) CreateRepositoryOperation(ctx context.Context, arg CreateRepositoryOperationParams) (RepositoryOperation, error) { + row := q.db.QueryRow(ctx, createRepositoryOperation, + arg.ID, + arg.RepositoryID, + arg.ActorUserID, + arg.ProviderConnectionID, + arg.CredentialVersion, + arg.RepositoryVersion, + arg.Kind, + arg.RequestedProviderType, + arg.RequestedRemoteUrl, + arg.RequestedNormalizedUrl, + arg.RequestedRef, + arg.PreviousProviderType, + arg.PreviousRemoteUrl, + arg.PreviousNormalizedUrl, + arg.PreviousRef, + ) + var i RepositoryOperation + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.ActorUserID, + &i.ProviderConnectionID, + &i.CredentialVersion, + &i.RepositoryVersion, + &i.Kind, + &i.RequestedProviderType, + &i.RequestedRemoteUrl, + &i.RequestedNormalizedUrl, + &i.RequestedRef, + &i.PreviousProviderType, + &i.PreviousRemoteUrl, + &i.PreviousNormalizedUrl, + &i.PreviousRef, + &i.Status, + &i.Outcome, + &i.ResolvedCommitSha, + &i.SnapshotID, + &i.WorkflowRunID, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const deleteRepository = `-- name: DeleteRepository :execrows +DELETE FROM repositories WHERE id=$1 AND workspace_id=$2 +` + +type DeleteRepositoryParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteRepository(ctx context.Context, arg DeleteRepositoryParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteRepository, arg.ID, arg.WorkspaceID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const ensureSnapshot = `-- name: EnsureSnapshot :one +INSERT INTO commit_snapshots (id, repository_id, commit_sha, source_ref, git_ref, author_name, authored_at, title, source_state) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'available') +ON CONFLICT (repository_id, commit_sha) DO UPDATE +SET id=commit_snapshots.id +RETURNING id, repository_id, commit_sha, source_ref, git_ref, author_name, authored_at, title, source_state, purge_cleanup_id, created_at +` + +type EnsureSnapshotParams struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` + CommitSha string `json:"commit_sha"` + SourceRef string `json:"source_ref"` + GitRef string `json:"git_ref"` + AuthorName pgtype.Text `json:"author_name"` + AuthoredAt pgtype.Timestamptz `json:"authored_at"` + Title pgtype.Text `json:"title"` +} + +func (q *Queries) EnsureSnapshot(ctx context.Context, arg EnsureSnapshotParams) (CommitSnapshot, error) { + row := q.db.QueryRow(ctx, ensureSnapshot, + arg.ID, + arg.RepositoryID, + arg.CommitSha, + arg.SourceRef, + arg.GitRef, + arg.AuthorName, + arg.AuthoredAt, + arg.Title, + ) + var i CommitSnapshot + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.CommitSha, + &i.SourceRef, + &i.GitRef, + &i.AuthorName, + &i.AuthoredAt, + &i.Title, + &i.SourceState, + &i.PurgeCleanupID, + &i.CreatedAt, + ) + return i, err +} + +const failRepositoryOperation = `-- name: FailRepositoryOperation :one +UPDATE repository_operations SET status='failed', error_message=$2, finished_at=now() +WHERE id=$1 AND status IN ('queued','running') RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at +` + +type FailRepositoryOperationParams struct { + ID uuid.UUID `json:"id"` + ErrorMessage pgtype.Text `json:"error_message"` +} + +func (q *Queries) FailRepositoryOperation(ctx context.Context, arg FailRepositoryOperationParams) (RepositoryOperation, error) { + row := q.db.QueryRow(ctx, failRepositoryOperation, arg.ID, arg.ErrorMessage) + var i RepositoryOperation + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.ActorUserID, + &i.ProviderConnectionID, + &i.CredentialVersion, + &i.RepositoryVersion, + &i.Kind, + &i.RequestedProviderType, + &i.RequestedRemoteUrl, + &i.RequestedNormalizedUrl, + &i.RequestedRef, + &i.PreviousProviderType, + &i.PreviousRemoteUrl, + &i.PreviousNormalizedUrl, + &i.PreviousRef, + &i.Status, + &i.Outcome, + &i.ResolvedCommitSha, + &i.SnapshotID, + &i.WorkflowRunID, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const finishRepositoryOperation = `-- name: FinishRepositoryOperation :one +UPDATE repository_operations +SET status='succeeded', outcome=$2, resolved_commit_sha=$3, snapshot_id=$4, finished_at=now() +WHERE id=$1 AND status='running' RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at +` + +type FinishRepositoryOperationParams struct { + ID uuid.UUID `json:"id"` + Outcome pgtype.Text `json:"outcome"` + ResolvedCommitSha pgtype.Text `json:"resolved_commit_sha"` + SnapshotID uuid.NullUUID `json:"snapshot_id"` +} + +func (q *Queries) FinishRepositoryOperation(ctx context.Context, arg FinishRepositoryOperationParams) (RepositoryOperation, error) { + row := q.db.QueryRow(ctx, finishRepositoryOperation, + arg.ID, + arg.Outcome, + arg.ResolvedCommitSha, + arg.SnapshotID, + ) + var i RepositoryOperation + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.ActorUserID, + &i.ProviderConnectionID, + &i.CredentialVersion, + &i.RepositoryVersion, + &i.Kind, + &i.RequestedProviderType, + &i.RequestedRemoteUrl, + &i.RequestedNormalizedUrl, + &i.RequestedRef, + &i.PreviousProviderType, + &i.PreviousRemoteUrl, + &i.PreviousNormalizedUrl, + &i.PreviousRef, + &i.Status, + &i.Outcome, + &i.ResolvedCommitSha, + &i.SnapshotID, + &i.WorkflowRunID, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const getRepository = `-- name: GetRepository :one +SELECT id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at FROM repositories WHERE id=$1 AND workspace_id=$2 AND status <> 'deleted' +` + +type GetRepositoryParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) GetRepository(ctx context.Context, arg GetRepositoryParams) (Repository, error) { + row := q.db.QueryRow(ctx, getRepository, arg.ID, arg.WorkspaceID) + var i Repository + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getRepositoryOperation = `-- name: GetRepositoryOperation :one +SELECT id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at FROM repository_operations WHERE id=$1 +` + +func (q *Queries) GetRepositoryOperation(ctx context.Context, id uuid.UUID) (RepositoryOperation, error) { + row := q.db.QueryRow(ctx, getRepositoryOperation, id) + var i RepositoryOperation + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.ActorUserID, + &i.ProviderConnectionID, + &i.CredentialVersion, + &i.RepositoryVersion, + &i.Kind, + &i.RequestedProviderType, + &i.RequestedRemoteUrl, + &i.RequestedNormalizedUrl, + &i.RequestedRef, + &i.PreviousProviderType, + &i.PreviousRemoteUrl, + &i.PreviousNormalizedUrl, + &i.PreviousRef, + &i.Status, + &i.Outcome, + &i.ResolvedCommitSha, + &i.SnapshotID, + &i.WorkflowRunID, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const getSnapshot = `-- name: GetSnapshot :one +SELECT id, repository_id, commit_sha, source_ref, git_ref, author_name, authored_at, title, source_state, purge_cleanup_id, created_at FROM commit_snapshots WHERE id=$1 AND repository_id=$2 +` + +type GetSnapshotParams struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` +} + +func (q *Queries) GetSnapshot(ctx context.Context, arg GetSnapshotParams) (CommitSnapshot, error) { + row := q.db.QueryRow(ctx, getSnapshot, arg.ID, arg.RepositoryID) + var i CommitSnapshot + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.CommitSha, + &i.SourceRef, + &i.GitRef, + &i.AuthorName, + &i.AuthoredAt, + &i.Title, + &i.SourceState, + &i.PurgeCleanupID, + &i.CreatedAt, + ) + return i, err +} + +const getWorkspaceSnapshot = `-- name: GetWorkspaceSnapshot :one +SELECT s.id, s.repository_id, s.commit_sha, s.source_ref, s.git_ref, s.author_name, s.authored_at, s.title, s.source_state, s.purge_cleanup_id, s.created_at FROM commit_snapshots s +JOIN repositories r ON r.id=s.repository_id +WHERE s.id=$1 AND s.repository_id=$2 AND r.workspace_id=$3 +` + +type GetWorkspaceSnapshotParams struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) GetWorkspaceSnapshot(ctx context.Context, arg GetWorkspaceSnapshotParams) (CommitSnapshot, error) { + row := q.db.QueryRow(ctx, getWorkspaceSnapshot, arg.ID, arg.RepositoryID, arg.WorkspaceID) + var i CommitSnapshot + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.CommitSha, + &i.SourceRef, + &i.GitRef, + &i.AuthorName, + &i.AuthoredAt, + &i.Title, + &i.SourceState, + &i.PurgeCleanupID, + &i.CreatedAt, + ) + return i, err +} + +const hasRepositoryAnalysisRuns = `-- name: HasRepositoryAnalysisRuns :one +SELECT EXISTS (SELECT 1 FROM analysis_runs WHERE repository_id=$1) +` + +func (q *Queries) HasRepositoryAnalysisRuns(ctx context.Context, repositoryID uuid.UUID) (bool, error) { + row := q.db.QueryRow(ctx, hasRepositoryAnalysisRuns, repositoryID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const listRepositories = `-- name: ListRepositories :many +SELECT r.id, r.workspace_id, r.provider_type, r.name, r.remote_url, r.normalized_url, r.configured_ref, r.config_version, r.git_path, r.status, r.current_snapshot_id, r.mirror_size_bytes, r.last_sync_at, r.last_error_code, r.last_error_message, r.archived_at, r.deleted_at, r.created_by, r.created_at, r.updated_at, s.commit_sha AS current_commit_sha, s.author_name AS current_author_name, + s.source_ref AS current_source_ref, s.authored_at AS current_authored_at, s.title AS current_title, + s.source_state AS current_source_state, s.created_at AS snapshot_created_at +FROM repositories r LEFT JOIN commit_snapshots s ON s.id=r.current_snapshot_id +WHERE r.workspace_id=$1 + AND r.status <> 'deleted' + AND ( + $2::timestamptz IS NULL + OR (r.created_at, r.id) < ($2, $3::uuid) + ) +ORDER BY r.created_at DESC, r.id DESC +LIMIT $4 +` + +type ListRepositoriesParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +type ListRepositoriesRow struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ProviderType string `json:"provider_type"` + Name string `json:"name"` + RemoteUrl string `json:"remote_url"` + NormalizedUrl string `json:"normalized_url"` + ConfiguredRef string `json:"configured_ref"` + ConfigVersion int64 `json:"config_version"` + GitPath string `json:"git_path"` + Status string `json:"status"` + CurrentSnapshotID uuid.NullUUID `json:"current_snapshot_id"` + MirrorSizeBytes int64 `json:"mirror_size_bytes"` + LastSyncAt pgtype.Timestamptz `json:"last_sync_at"` + LastErrorCode pgtype.Text `json:"last_error_code"` + LastErrorMessage pgtype.Text `json:"last_error_message"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CurrentCommitSha pgtype.Text `json:"current_commit_sha"` + CurrentAuthorName pgtype.Text `json:"current_author_name"` + CurrentSourceRef pgtype.Text `json:"current_source_ref"` + CurrentAuthoredAt pgtype.Timestamptz `json:"current_authored_at"` + CurrentTitle pgtype.Text `json:"current_title"` + CurrentSourceState pgtype.Text `json:"current_source_state"` + SnapshotCreatedAt pgtype.Timestamptz `json:"snapshot_created_at"` +} + +func (q *Queries) ListRepositories(ctx context.Context, arg ListRepositoriesParams) ([]ListRepositoriesRow, error) { + rows, err := q.db.Query(ctx, listRepositories, + arg.WorkspaceID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRepositoriesRow{} + for rows.Next() { + var i ListRepositoriesRow + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.CurrentCommitSha, + &i.CurrentAuthorName, + &i.CurrentSourceRef, + &i.CurrentAuthoredAt, + &i.CurrentTitle, + &i.CurrentSourceState, + &i.SnapshotCreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRepositoryOperations = `-- name: ListRepositoryOperations :many +SELECT id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at FROM repository_operations +WHERE repository_id=$1 + AND ( + $2::timestamptz IS NULL + OR (created_at, id) < ($2, $3::uuid) + ) +ORDER BY created_at DESC, id DESC +LIMIT $4 +` + +type ListRepositoryOperationsParams struct { + RepositoryID uuid.UUID `json:"repository_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListRepositoryOperations(ctx context.Context, arg ListRepositoryOperationsParams) ([]RepositoryOperation, error) { + rows, err := q.db.Query(ctx, listRepositoryOperations, + arg.RepositoryID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []RepositoryOperation{} + for rows.Next() { + var i RepositoryOperation + if err := rows.Scan( + &i.ID, + &i.RepositoryID, + &i.ActorUserID, + &i.ProviderConnectionID, + &i.CredentialVersion, + &i.RepositoryVersion, + &i.Kind, + &i.RequestedProviderType, + &i.RequestedRemoteUrl, + &i.RequestedNormalizedUrl, + &i.RequestedRef, + &i.PreviousProviderType, + &i.PreviousRemoteUrl, + &i.PreviousNormalizedUrl, + &i.PreviousRef, + &i.Status, + &i.Outcome, + &i.ResolvedCommitSha, + &i.SnapshotID, + &i.WorkflowRunID, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRepositorySnapshots = `-- name: ListRepositorySnapshots :many +SELECT s.id, s.repository_id, s.commit_sha, s.source_ref, s.git_ref, s.author_name, s.authored_at, s.title, s.source_state, s.purge_cleanup_id, s.created_at FROM commit_snapshots s +JOIN repositories r ON r.id=s.repository_id +WHERE s.repository_id=$1 AND r.workspace_id=$2 + AND ( + $3::timestamptz IS NULL + OR (s.created_at, s.id) < ($3, $4::uuid) + ) +ORDER BY s.created_at DESC, s.id DESC +LIMIT $5 +` + +type ListRepositorySnapshotsParams struct { + RepositoryID uuid.UUID `json:"repository_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID uuid.NullUUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListRepositorySnapshots(ctx context.Context, arg ListRepositorySnapshotsParams) ([]CommitSnapshot, error) { + rows, err := q.db.Query(ctx, listRepositorySnapshots, + arg.RepositoryID, + arg.WorkspaceID, + arg.CursorTime, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []CommitSnapshot{} + for rows.Next() { + var i CommitSnapshot + if err := rows.Scan( + &i.ID, + &i.RepositoryID, + &i.CommitSha, + &i.SourceRef, + &i.GitRef, + &i.AuthorName, + &i.AuthoredAt, + &i.Title, + &i.SourceState, + &i.PurgeCleanupID, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markSnapshotsPurged = `-- name: MarkSnapshotsPurged :exec +UPDATE commit_snapshots SET source_state='purged' WHERE repository_id=$1 +` + +func (q *Queries) MarkSnapshotsPurged(ctx context.Context, repositoryID uuid.UUID) error { + _, err := q.db.Exec(ctx, markSnapshotsPurged, repositoryID) + return err +} + +const projectRepositoryOperationFailure = `-- name: ProjectRepositoryOperationFailure :execrows +UPDATE repositories +SET status=CASE WHEN current_snapshot_id IS NULL THEN 'failed' ELSE 'ready' END, + provider_type=$1, + remote_url=$2, + normalized_url=$3, + configured_ref=$4, + last_error_code=$5, last_error_message=$6, updated_at=now() +WHERE id=$7 AND config_version=$8 AND status <> 'deleted' +` + +type ProjectRepositoryOperationFailureParams struct { + PreviousProviderType string `json:"previous_provider_type"` + PreviousRemoteUrl string `json:"previous_remote_url"` + PreviousNormalizedUrl string `json:"previous_normalized_url"` + PreviousRef string `json:"previous_ref"` + LastErrorCode pgtype.Text `json:"last_error_code"` + LastErrorMessage pgtype.Text `json:"last_error_message"` + ID uuid.UUID `json:"id"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) ProjectRepositoryOperationFailure(ctx context.Context, arg ProjectRepositoryOperationFailureParams) (int64, error) { + result, err := q.db.Exec(ctx, projectRepositoryOperationFailure, + arg.PreviousProviderType, + arg.PreviousRemoteUrl, + arg.PreviousNormalizedUrl, + arg.PreviousRef, + arg.LastErrorCode, + arg.LastErrorMessage, + arg.ID, + arg.ConfigVersion, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const releaseRepositorySource = `-- name: ReleaseRepositorySource :execrows +DELETE FROM repository_source_keys +WHERE workspace_id=$1 AND normalized_url=$2 AND repository_id=$3 +` + +type ReleaseRepositorySourceParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + NormalizedUrl string `json:"normalized_url"` + RepositoryID uuid.UUID `json:"repository_id"` +} + +func (q *Queries) ReleaseRepositorySource(ctx context.Context, arg ReleaseRepositorySourceParams) (int64, error) { + result, err := q.db.Exec(ctx, releaseRepositorySource, arg.WorkspaceID, arg.NormalizedUrl, arg.RepositoryID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const requestRepositoryDeletion = `-- name: RequestRepositoryDeletion :one +UPDATE repositories SET status='deleting', updated_at=now() +WHERE id=$1 AND workspace_id=$2 + AND status NOT IN ('provisioning','syncing','deleting','deleted') +RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at +` + +type RequestRepositoryDeletionParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) RequestRepositoryDeletion(ctx context.Context, arg RequestRepositoryDeletionParams) (Repository, error) { + row := q.db.QueryRow(ctx, requestRepositoryDeletion, arg.ID, arg.WorkspaceID) + var i Repository + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const reserveRepositorySource = `-- name: ReserveRepositorySource :one +INSERT INTO repository_source_keys (workspace_id, normalized_url, repository_id) +VALUES ($1,$2,$3) +ON CONFLICT (workspace_id, normalized_url) DO UPDATE +SET normalized_url=excluded.normalized_url +WHERE repository_source_keys.repository_id=excluded.repository_id +RETURNING repository_id +` + +type ReserveRepositorySourceParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + NormalizedUrl string `json:"normalized_url"` + RepositoryID uuid.UUID `json:"repository_id"` +} + +func (q *Queries) ReserveRepositorySource(ctx context.Context, arg ReserveRepositorySourceParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, reserveRepositorySource, arg.WorkspaceID, arg.NormalizedUrl, arg.RepositoryID) + var repository_id uuid.UUID + err := row.Scan(&repository_id) + return repository_id, err +} + +const restoreDeletedRepositoryAfterPurgeFailure = `-- name: RestoreDeletedRepositoryAfterPurgeFailure :execrows +UPDATE repositories +SET status='deleted', last_error_code=$1, + last_error_message=$2, updated_at=now() +WHERE id=$3 AND config_version=$4 + AND deleted_at IS NOT NULL AND status='deleting' +` + +type RestoreDeletedRepositoryAfterPurgeFailureParams struct { + LastErrorCode pgtype.Text `json:"last_error_code"` + LastErrorMessage pgtype.Text `json:"last_error_message"` + ID uuid.UUID `json:"id"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) RestoreDeletedRepositoryAfterPurgeFailure(ctx context.Context, arg RestoreDeletedRepositoryAfterPurgeFailureParams) (int64, error) { + result, err := q.db.Exec(ctx, restoreDeletedRepositoryAfterPurgeFailure, + arg.LastErrorCode, + arg.LastErrorMessage, + arg.ID, + arg.ConfigVersion, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const restoreRepository = `-- name: RestoreRepository :one +UPDATE repositories SET archived_at=NULL, updated_at=now() +WHERE id=$1 AND workspace_id=$2 RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at +` + +type RestoreRepositoryParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +func (q *Queries) RestoreRepository(ctx context.Context, arg RestoreRepositoryParams) (Repository, error) { + row := q.db.QueryRow(ctx, restoreRepository, arg.ID, arg.WorkspaceID) + var i Repository + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const restoreRepositoryAfterOperation = `-- name: RestoreRepositoryAfterOperation :execrows +UPDATE repositories +SET status=CASE WHEN current_snapshot_id IS NULL THEN 'failed' ELSE 'ready' END, + provider_type=$1, + remote_url=$2, + normalized_url=$3, + configured_ref=$4, + updated_at=now() +WHERE id=$5 AND config_version=$6 AND status IN ('syncing','deleting') +` + +type RestoreRepositoryAfterOperationParams struct { + PreviousProviderType string `json:"previous_provider_type"` + PreviousRemoteUrl string `json:"previous_remote_url"` + PreviousNormalizedUrl string `json:"previous_normalized_url"` + PreviousRef string `json:"previous_ref"` + ID uuid.UUID `json:"id"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) RestoreRepositoryAfterOperation(ctx context.Context, arg RestoreRepositoryAfterOperationParams) (int64, error) { + result, err := q.db.Exec(ctx, restoreRepositoryAfterOperation, + arg.PreviousProviderType, + arg.PreviousRemoteUrl, + arg.PreviousNormalizedUrl, + arg.PreviousRef, + arg.ID, + arg.ConfigVersion, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setRepositoryCurrentSnapshot = `-- name: SetRepositoryCurrentSnapshot :one +UPDATE repositories +SET current_snapshot_id=$1, + provider_type=$2, remote_url=$3, + normalized_url=$4, configured_ref=$5, + status='ready', mirror_size_bytes=$6, + last_sync_at=now(), last_error_code=NULL, last_error_message=NULL, updated_at=now() +WHERE id=$7 AND workspace_id=$8 + AND config_version=$9 RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at +` + +type SetRepositoryCurrentSnapshotParams struct { + CurrentSnapshotID uuid.NullUUID `json:"current_snapshot_id"` + ProviderType string `json:"provider_type"` + RemoteUrl string `json:"remote_url"` + NormalizedUrl string `json:"normalized_url"` + ConfiguredRef string `json:"configured_ref"` + MirrorSizeBytes int64 `json:"mirror_size_bytes"` + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) SetRepositoryCurrentSnapshot(ctx context.Context, arg SetRepositoryCurrentSnapshotParams) (Repository, error) { + row := q.db.QueryRow(ctx, setRepositoryCurrentSnapshot, + arg.CurrentSnapshotID, + arg.ProviderType, + arg.RemoteUrl, + arg.NormalizedUrl, + arg.ConfiguredRef, + arg.MirrorSizeBytes, + arg.ID, + arg.WorkspaceID, + arg.ConfigVersion, + ) + var i Repository + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const setRepositoryOperationWorkflowID = `-- name: SetRepositoryOperationWorkflowID :exec +UPDATE repository_operations SET workflow_run_id=$2 WHERE id=$1 +` + +type SetRepositoryOperationWorkflowIDParams struct { + ID uuid.UUID `json:"id"` + WorkflowRunID pgtype.Text `json:"workflow_run_id"` +} + +func (q *Queries) SetRepositoryOperationWorkflowID(ctx context.Context, arg SetRepositoryOperationWorkflowIDParams) error { + _, err := q.db.Exec(ctx, setRepositoryOperationWorkflowID, arg.ID, arg.WorkflowRunID) + return err +} + +const setRepositoryStatus = `-- name: SetRepositoryStatus :exec +UPDATE repositories SET status=$2, updated_at=now() WHERE id=$1 +` + +type SetRepositoryStatusParams struct { + ID uuid.UUID `json:"id"` + Status string `json:"status"` +} + +func (q *Queries) SetRepositoryStatus(ctx context.Context, arg SetRepositoryStatusParams) error { + _, err := q.db.Exec(ctx, setRepositoryStatus, arg.ID, arg.Status) + return err +} + +const setRepositorySyncing = `-- name: SetRepositorySyncing :execrows +UPDATE repositories +SET status='syncing', updated_at=now() +WHERE id=$1 AND config_version=$2 AND status NOT IN ('deleting','deleted') +` + +type SetRepositorySyncingParams struct { + ID uuid.UUID `json:"id"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) SetRepositorySyncing(ctx context.Context, arg SetRepositorySyncingParams) (int64, error) { + result, err := q.db.Exec(ctx, setRepositorySyncing, arg.ID, arg.ConfigVersion) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const stageRepositoryUpdate = `-- name: StageRepositoryUpdate :one +UPDATE repositories +SET name=$1, provider_type=$2, + remote_url=$3, normalized_url=$4, + configured_ref=$5, config_version=config_version+1, status='syncing', + updated_at=now() +WHERE id=$6 AND workspace_id=$7 + AND config_version=$8 + AND status NOT IN ('provisioning','syncing','deleting','deleted') +RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at +` + +type StageRepositoryUpdateParams struct { + Name string `json:"name"` + ProviderType string `json:"provider_type"` + RemoteUrl string `json:"remote_url"` + NormalizedUrl string `json:"normalized_url"` + ConfiguredRef string `json:"configured_ref"` + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ConfigVersion int64 `json:"config_version"` +} + +func (q *Queries) StageRepositoryUpdate(ctx context.Context, arg StageRepositoryUpdateParams) (Repository, error) { + row := q.db.QueryRow(ctx, stageRepositoryUpdate, + arg.Name, + arg.ProviderType, + arg.RemoteUrl, + arg.NormalizedUrl, + arg.ConfiguredRef, + arg.ID, + arg.WorkspaceID, + arg.ConfigVersion, + ) + var i Repository + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const startRepositoryOperation = `-- name: StartRepositoryOperation :one +UPDATE repository_operations SET status='running', started_at=now() +WHERE id=$1 AND status='queued' RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at +` + +func (q *Queries) StartRepositoryOperation(ctx context.Context, id uuid.UUID) (RepositoryOperation, error) { + row := q.db.QueryRow(ctx, startRepositoryOperation, id) + var i RepositoryOperation + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.ActorUserID, + &i.ProviderConnectionID, + &i.CredentialVersion, + &i.RepositoryVersion, + &i.Kind, + &i.RequestedProviderType, + &i.RequestedRemoteUrl, + &i.RequestedNormalizedUrl, + &i.RequestedRef, + &i.PreviousProviderType, + &i.PreviousRemoteUrl, + &i.PreviousNormalizedUrl, + &i.PreviousRef, + &i.Status, + &i.Outcome, + &i.ResolvedCommitSha, + &i.SnapshotID, + &i.WorkflowRunID, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const updateRepository = `-- name: UpdateRepository :one +UPDATE repositories SET name=$3, updated_at=now() +WHERE id=$1 AND workspace_id=$2 RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at +` + +type UpdateRepositoryParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Name string `json:"name"` +} + +func (q *Queries) UpdateRepository(ctx context.Context, arg UpdateRepositoryParams) (Repository, error) { + row := q.db.QueryRow(ctx, updateRepository, arg.ID, arg.WorkspaceID, arg.Name) + var i Repository + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/data/sqlc/retention.sql.go b/internal/data/sqlc/retention.sql.go new file mode 100644 index 0000000..98d5748 --- /dev/null +++ b/internal/data/sqlc/retention.sql.go @@ -0,0 +1,527 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: retention.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const addRequeuedRepositoryCount = `-- name: AddRequeuedRepositoryCount :execrows +UPDATE retention_cleanups +SET requeued_repository_count = requeued_repository_count + $2 +WHERE id = $1 AND status = 'running' +` + +type AddRequeuedRepositoryCountParams struct { + ID uuid.UUID `json:"id"` + RequeuedRepositoryCount int32 `json:"requeued_repository_count"` +} + +func (q *Queries) AddRequeuedRepositoryCount(ctx context.Context, arg AddRequeuedRepositoryCountParams) (int64, error) { + result, err := q.db.Exec(ctx, addRequeuedRepositoryCount, arg.ID, arg.RequeuedRepositoryCount) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const addRetentionCleanupProgress = `-- name: AddRetentionCleanupProgress :execrows +UPDATE retention_cleanups +SET deleted_run_count = deleted_run_count + $1::integer, + purged_snapshot_count = purged_snapshot_count + $2::integer +WHERE id = $3 AND status = 'running' +` + +type AddRetentionCleanupProgressParams struct { + DeletedRuns int32 `json:"deleted_runs"` + PurgedSnapshots int32 `json:"purged_snapshots"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) AddRetentionCleanupProgress(ctx context.Context, arg AddRetentionCleanupProgressParams) (int64, error) { + result, err := q.db.Exec(ctx, addRetentionCleanupProgress, arg.DeletedRuns, arg.PurgedSnapshots, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const advanceRetentionSchedule = `-- name: AdvanceRetentionSchedule :execrows +UPDATE retention_cleanup_schedules +SET last_run_at = clock_timestamp(), + next_run_at = GREATEST(next_run_at + INTERVAL '1 day', clock_timestamp() + INTERVAL '1 day'), + updated_at = clock_timestamp() +WHERE workspace_id = $1 AND next_run_at = $2 +` + +type AdvanceRetentionScheduleParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` +} + +func (q *Queries) AdvanceRetentionSchedule(ctx context.Context, arg AdvanceRetentionScheduleParams) (int64, error) { + result, err := q.db.Exec(ctx, advanceRetentionSchedule, arg.WorkspaceID, arg.NextRunAt) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const claimDueRetentionSchedules = `-- name: ClaimDueRetentionSchedules :many +SELECT schedule.workspace_id, schedule.next_run_at, workspace.report_retention_days +FROM retention_cleanup_schedules AS schedule +JOIN workspaces AS workspace ON workspace.id = schedule.workspace_id +WHERE schedule.next_run_at <= clock_timestamp() +ORDER BY schedule.next_run_at, schedule.workspace_id +FOR UPDATE OF schedule SKIP LOCKED +LIMIT $1 +` + +type ClaimDueRetentionSchedulesRow struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + ReportRetentionDays int32 `json:"report_retention_days"` +} + +func (q *Queries) ClaimDueRetentionSchedules(ctx context.Context, limit int32) ([]ClaimDueRetentionSchedulesRow, error) { + rows, err := q.db.Query(ctx, claimDueRetentionSchedules, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ClaimDueRetentionSchedulesRow{} + for rows.Next() { + var i ClaimDueRetentionSchedulesRow + if err := rows.Scan(&i.WorkspaceID, &i.NextRunAt, &i.ReportRetentionDays); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const claimRepositoriesReadyForPurge = `-- name: ClaimRepositoriesReadyForPurge :many +SELECT repository.id, repository.workspace_id, repository.provider_type, repository.name, repository.remote_url, repository.normalized_url, repository.configured_ref, repository.config_version, repository.git_path, repository.status, repository.current_snapshot_id, repository.mirror_size_bytes, repository.last_sync_at, repository.last_error_code, repository.last_error_message, repository.archived_at, repository.deleted_at, repository.created_by, repository.created_at, repository.updated_at +FROM repositories AS repository +WHERE repository.workspace_id = $1 + AND repository.status = 'deleted' + AND NOT EXISTS ( + SELECT 1 FROM analysis_runs AS run WHERE run.repository_id = repository.id + ) + AND NOT EXISTS ( + SELECT 1 FROM repository_operations AS operation + WHERE operation.repository_id = repository.id + AND operation.kind = 'purge' + AND operation.status IN ('queued', 'running') + ) +ORDER BY repository.deleted_at, repository.id +FOR UPDATE OF repository SKIP LOCKED +LIMIT $2 +` + +type ClaimRepositoriesReadyForPurgeParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ClaimRepositoriesReadyForPurge(ctx context.Context, arg ClaimRepositoriesReadyForPurgeParams) ([]Repository, error) { + rows, err := q.db.Query(ctx, claimRepositoriesReadyForPurge, arg.WorkspaceID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Repository{} + for rows.Next() { + var i Repository + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProviderType, + &i.Name, + &i.RemoteUrl, + &i.NormalizedUrl, + &i.ConfiguredRef, + &i.ConfigVersion, + &i.GitPath, + &i.Status, + &i.CurrentSnapshotID, + &i.MirrorSizeBytes, + &i.LastSyncAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.ArchivedAt, + &i.DeletedAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const completeSnapshotPurge = `-- name: CompleteSnapshotPurge :execrows +UPDATE commit_snapshots +SET source_state = 'purged', purge_cleanup_id = NULL +WHERE id = $1 AND repository_id = $2 AND purge_cleanup_id = $3 AND source_state = 'purging' +` + +type CompleteSnapshotPurgeParams struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` + PurgeCleanupID uuid.NullUUID `json:"purge_cleanup_id"` +} + +func (q *Queries) CompleteSnapshotPurge(ctx context.Context, arg CompleteSnapshotPurgeParams) (int64, error) { + result, err := q.db.Exec(ctx, completeSnapshotPurge, arg.ID, arg.RepositoryID, arg.PurgeCleanupID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const createRetentionCleanup = `-- name: CreateRetentionCleanup :one +INSERT INTO retention_cleanups (id, workspace_id, scheduled_for, retention_days, status) +VALUES ($1, $2, $3, $4, 'queued') +RETURNING id, workspace_id, scheduled_for, retention_days, status, workflow_run_id, deleted_run_count, purged_snapshot_count, requeued_repository_count, error_message, created_at, started_at, finished_at +` + +type CreateRetentionCleanupParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ScheduledFor pgtype.Timestamptz `json:"scheduled_for"` + RetentionDays int32 `json:"retention_days"` +} + +func (q *Queries) CreateRetentionCleanup(ctx context.Context, arg CreateRetentionCleanupParams) (RetentionCleanup, error) { + row := q.db.QueryRow(ctx, createRetentionCleanup, + arg.ID, + arg.WorkspaceID, + arg.ScheduledFor, + arg.RetentionDays, + ) + var i RetentionCleanup + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ScheduledFor, + &i.RetentionDays, + &i.Status, + &i.WorkflowRunID, + &i.DeletedRunCount, + &i.PurgedSnapshotCount, + &i.RequeuedRepositoryCount, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const deleteExpiredAnalysisRuns = `-- name: DeleteExpiredAnalysisRuns :many +WITH candidates AS ( + SELECT analysis_run.id + FROM analysis_runs AS analysis_run + WHERE analysis_run.workspace_id = $1 + AND analysis_run.status IN ('succeeded', 'failed', 'cancelled') + AND analysis_run.finished_at < clock_timestamp() - make_interval(days => $2::integer) + ORDER BY analysis_run.finished_at, analysis_run.id + FOR UPDATE SKIP LOCKED + LIMIT $3 +) +DELETE FROM analysis_runs AS run +USING candidates +WHERE run.id = candidates.id +RETURNING run.snapshot_id +` + +type DeleteExpiredAnalysisRunsParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + RetentionDays int32 `json:"retention_days"` + Limit int32 `json:"limit"` +} + +func (q *Queries) DeleteExpiredAnalysisRuns(ctx context.Context, arg DeleteExpiredAnalysisRunsParams) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, deleteExpiredAnalysisRuns, arg.WorkspaceID, arg.RetentionDays, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []uuid.UUID{} + for rows.Next() { + var snapshot_id uuid.UUID + if err := rows.Scan(&snapshot_id); err != nil { + return nil, err + } + items = append(items, snapshot_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const failRetentionCleanup = `-- name: FailRetentionCleanup :execrows +UPDATE retention_cleanups +SET status = 'failed', error_message = $2, finished_at = clock_timestamp() +WHERE id = $1 AND status IN ('queued', 'running') +` + +type FailRetentionCleanupParams struct { + ID uuid.UUID `json:"id"` + ErrorMessage pgtype.Text `json:"error_message"` +} + +func (q *Queries) FailRetentionCleanup(ctx context.Context, arg FailRetentionCleanupParams) (int64, error) { + result, err := q.db.Exec(ctx, failRetentionCleanup, arg.ID, arg.ErrorMessage) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const finishRetentionCleanup = `-- name: FinishRetentionCleanup :one +UPDATE retention_cleanups +SET status = 'succeeded', finished_at = clock_timestamp(), error_message = NULL +WHERE id = $1 AND status = 'running' +RETURNING id, workspace_id, scheduled_for, retention_days, status, workflow_run_id, deleted_run_count, purged_snapshot_count, requeued_repository_count, error_message, created_at, started_at, finished_at +` + +func (q *Queries) FinishRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error) { + row := q.db.QueryRow(ctx, finishRetentionCleanup, id) + var i RetentionCleanup + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ScheduledFor, + &i.RetentionDays, + &i.Status, + &i.WorkflowRunID, + &i.DeletedRunCount, + &i.PurgedSnapshotCount, + &i.RequeuedRepositoryCount, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const getRetentionCleanup = `-- name: GetRetentionCleanup :one +SELECT id, workspace_id, scheduled_for, retention_days, status, workflow_run_id, deleted_run_count, purged_snapshot_count, requeued_repository_count, error_message, created_at, started_at, finished_at FROM retention_cleanups WHERE id = $1 +` + +func (q *Queries) GetRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error) { + row := q.db.QueryRow(ctx, getRetentionCleanup, id) + var i RetentionCleanup + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ScheduledFor, + &i.RetentionDays, + &i.Status, + &i.WorkflowRunID, + &i.DeletedRunCount, + &i.PurgedSnapshotCount, + &i.RequeuedRepositoryCount, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const lockPurgeableSnapshotCandidates = `-- name: LockPurgeableSnapshotCandidates :many +SELECT snapshot.id +FROM commit_snapshots AS snapshot +JOIN repositories AS repository ON repository.id = snapshot.repository_id +WHERE repository.workspace_id = $1 + AND ( + snapshot.source_state = 'available' + OR ( + snapshot.source_state = 'purging' + AND ( + snapshot.purge_cleanup_id = $2 + OR EXISTS ( + SELECT 1 + FROM retention_cleanups AS previous_cleanup + WHERE previous_cleanup.id = snapshot.purge_cleanup_id + AND previous_cleanup.status = 'failed' + ) + ) + ) + ) + AND repository.current_snapshot_id IS DISTINCT FROM snapshot.id + AND NOT EXISTS ( + SELECT 1 FROM analysis_runs AS run WHERE run.snapshot_id = snapshot.id + ) +ORDER BY snapshot.created_at, snapshot.id +FOR UPDATE OF snapshot SKIP LOCKED +LIMIT $3 +` + +type LockPurgeableSnapshotCandidatesParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + CleanupID uuid.NullUUID `json:"cleanup_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) LockPurgeableSnapshotCandidates(ctx context.Context, arg LockPurgeableSnapshotCandidatesParams) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, lockPurgeableSnapshotCandidates, arg.WorkspaceID, arg.CleanupID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []uuid.UUID{} + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markSnapshotsPurging = `-- name: MarkSnapshotsPurging :many +UPDATE commit_snapshots AS snapshot +SET source_state = 'purging', purge_cleanup_id = $1 +FROM repositories AS repository +WHERE snapshot.id = ANY($2::uuid[]) + AND repository.id = snapshot.repository_id + AND repository.workspace_id = $3 + AND repository.current_snapshot_id IS DISTINCT FROM snapshot.id + AND NOT EXISTS ( + SELECT 1 FROM analysis_runs AS run WHERE run.snapshot_id = snapshot.id + ) + AND ( + snapshot.source_state = 'available' + OR ( + snapshot.source_state = 'purging' + AND ( + snapshot.purge_cleanup_id = $1 + OR EXISTS ( + SELECT 1 + FROM retention_cleanups AS previous_cleanup + WHERE previous_cleanup.id = snapshot.purge_cleanup_id + AND previous_cleanup.status = 'failed' + ) + ) + ) + ) +RETURNING snapshot.id, snapshot.repository_id, snapshot.git_ref, snapshot.commit_sha +` + +type MarkSnapshotsPurgingParams struct { + CleanupID uuid.NullUUID `json:"cleanup_id"` + SnapshotIds []uuid.UUID `json:"snapshot_ids"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +type MarkSnapshotsPurgingRow struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repository_id"` + GitRef string `json:"git_ref"` + CommitSha string `json:"commit_sha"` +} + +func (q *Queries) MarkSnapshotsPurging(ctx context.Context, arg MarkSnapshotsPurgingParams) ([]MarkSnapshotsPurgingRow, error) { + rows, err := q.db.Query(ctx, markSnapshotsPurging, arg.CleanupID, arg.SnapshotIds, arg.WorkspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []MarkSnapshotsPurgingRow{} + for rows.Next() { + var i MarkSnapshotsPurgingRow + if err := rows.Scan( + &i.ID, + &i.RepositoryID, + &i.GitRef, + &i.CommitSha, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const reactivateRepositoryPurge = `-- name: ReactivateRepositoryPurge :execrows +UPDATE repositories +SET status = 'deleting', updated_at = clock_timestamp() +WHERE id = $1 AND status = 'deleted' +` + +func (q *Queries) ReactivateRepositoryPurge(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.Exec(ctx, reactivateRepositoryPurge, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setRetentionCleanupWorkflowID = `-- name: SetRetentionCleanupWorkflowID :exec +UPDATE retention_cleanups SET workflow_run_id = $2 WHERE id = $1 +` + +type SetRetentionCleanupWorkflowIDParams struct { + ID uuid.UUID `json:"id"` + WorkflowRunID pgtype.Text `json:"workflow_run_id"` +} + +func (q *Queries) SetRetentionCleanupWorkflowID(ctx context.Context, arg SetRetentionCleanupWorkflowIDParams) error { + _, err := q.db.Exec(ctx, setRetentionCleanupWorkflowID, arg.ID, arg.WorkflowRunID) + return err +} + +const startRetentionCleanup = `-- name: StartRetentionCleanup :one +UPDATE retention_cleanups +SET status = 'running', started_at = COALESCE(started_at, clock_timestamp()), error_message = NULL +WHERE id = $1 AND status IN ('queued', 'running') +RETURNING id, workspace_id, scheduled_for, retention_days, status, workflow_run_id, deleted_run_count, purged_snapshot_count, requeued_repository_count, error_message, created_at, started_at, finished_at +` + +func (q *Queries) StartRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error) { + row := q.db.QueryRow(ctx, startRetentionCleanup, id) + var i RetentionCleanup + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ScheduledFor, + &i.RetentionDays, + &i.Status, + &i.WorkflowRunID, + &i.DeletedRunCount, + &i.PurgedSnapshotCount, + &i.RequeuedRepositoryCount, + &i.ErrorMessage, + &i.CreatedAt, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} diff --git a/internal/data/sqlc/workflows.sql.go b/internal/data/sqlc/workflows.sql.go new file mode 100644 index 0000000..aaed2f7 --- /dev/null +++ b/internal/data/sqlc/workflows.sql.go @@ -0,0 +1,160 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: workflows.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const cancelWorkflowDispatch = `-- name: CancelWorkflowDispatch :exec +UPDATE workflow_dispatches SET status='cancelled' +WHERE aggregate_type=$1 AND aggregate_id=$2 AND status='pending' +` + +type CancelWorkflowDispatchParams struct { + AggregateType string `json:"aggregate_type"` + AggregateID uuid.UUID `json:"aggregate_id"` +} + +func (q *Queries) CancelWorkflowDispatch(ctx context.Context, arg CancelWorkflowDispatchParams) error { + _, err := q.db.Exec(ctx, cancelWorkflowDispatch, arg.AggregateType, arg.AggregateID) + return err +} + +const claimWorkflowDispatches = `-- name: ClaimWorkflowDispatches :many +WITH candidates AS ( + SELECT id FROM workflow_dispatches + WHERE status='pending' AND available_at <= now() + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT $2 +) +UPDATE workflow_dispatches AS dispatch +SET attempts=dispatch.attempts+1, + available_at=now() + $1::interval +FROM candidates +WHERE dispatch.id=candidates.id +RETURNING dispatch.id, dispatch.aggregate_type, dispatch.aggregate_id, dispatch.workflow_name, dispatch.payload, dispatch.status, dispatch.attempts, dispatch.available_at, dispatch.last_error, dispatch.created_at, dispatch.dispatched_at +` + +type ClaimWorkflowDispatchesParams struct { + LeaseDuration pgtype.Interval `json:"lease_duration"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ClaimWorkflowDispatches(ctx context.Context, arg ClaimWorkflowDispatchesParams) ([]WorkflowDispatch, error) { + rows, err := q.db.Query(ctx, claimWorkflowDispatches, arg.LeaseDuration, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []WorkflowDispatch{} + for rows.Next() { + var i WorkflowDispatch + if err := rows.Scan( + &i.ID, + &i.AggregateType, + &i.AggregateID, + &i.WorkflowName, + &i.Payload, + &i.Status, + &i.Attempts, + &i.AvailableAt, + &i.LastError, + &i.CreatedAt, + &i.DispatchedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const createWorkflowDispatch = `-- name: CreateWorkflowDispatch :one +INSERT INTO workflow_dispatches (id, aggregate_type, aggregate_id, workflow_name, payload, status) +VALUES ($1,$2,$3,$4,$5,'pending') RETURNING id, aggregate_type, aggregate_id, workflow_name, payload, status, attempts, available_at, last_error, created_at, dispatched_at +` + +type CreateWorkflowDispatchParams struct { + ID uuid.UUID `json:"id"` + AggregateType string `json:"aggregate_type"` + AggregateID uuid.UUID `json:"aggregate_id"` + WorkflowName string `json:"workflow_name"` + Payload []byte `json:"payload"` +} + +func (q *Queries) CreateWorkflowDispatch(ctx context.Context, arg CreateWorkflowDispatchParams) (WorkflowDispatch, error) { + row := q.db.QueryRow(ctx, createWorkflowDispatch, + arg.ID, + arg.AggregateType, + arg.AggregateID, + arg.WorkflowName, + arg.Payload, + ) + var i WorkflowDispatch + err := row.Scan( + &i.ID, + &i.AggregateType, + &i.AggregateID, + &i.WorkflowName, + &i.Payload, + &i.Status, + &i.Attempts, + &i.AvailableAt, + &i.LastError, + &i.CreatedAt, + &i.DispatchedAt, + ) + return i, err +} + +const delayWorkflowDispatch = `-- name: DelayWorkflowDispatch :exec +UPDATE workflow_dispatches +SET available_at=$2, last_error=$3 +WHERE id=$1 AND status='pending' +` + +type DelayWorkflowDispatchParams struct { + ID uuid.UUID `json:"id"` + AvailableAt pgtype.Timestamptz `json:"available_at"` + LastError pgtype.Text `json:"last_error"` +} + +func (q *Queries) DelayWorkflowDispatch(ctx context.Context, arg DelayWorkflowDispatchParams) error { + _, err := q.db.Exec(ctx, delayWorkflowDispatch, arg.ID, arg.AvailableAt, arg.LastError) + return err +} + +const isWorkflowDispatchPending = `-- name: IsWorkflowDispatchPending :one +SELECT EXISTS(SELECT 1 FROM workflow_dispatches WHERE id=$1 AND status='pending') +` + +func (q *Queries) IsWorkflowDispatchPending(ctx context.Context, id uuid.UUID) (bool, error) { + row := q.db.QueryRow(ctx, isWorkflowDispatchPending, id) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const markWorkflowDispatched = `-- name: MarkWorkflowDispatched :execrows +UPDATE workflow_dispatches SET status='dispatched', dispatched_at=now() +WHERE id=$1 AND status='pending' +` + +func (q *Queries) MarkWorkflowDispatched(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.Exec(ctx, markWorkflowDispatched, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/internal/health/state.go b/internal/health/state.go deleted file mode 100644 index 7231576..0000000 --- a/internal/health/state.go +++ /dev/null @@ -1,50 +0,0 @@ -package health - -import ( - "sync" - "sync/atomic" -) - -type State struct { - ready atomic.Bool - shuttingDown atomic.Bool - componentsMu sync.RWMutex - components map[string]bool -} - -func NewState() *State { - return &State{components: make(map[string]bool)} -} - -func (s *State) SetReady(ready bool) { - s.ready.Store(ready) -} - -func (s *State) StartShutdown() { - s.shuttingDown.Store(true) - s.ready.Store(false) -} - -func (s *State) Live() bool { - return true -} - -func (s *State) Ready() bool { - if !s.ready.Load() || s.shuttingDown.Load() { - return false - } - s.componentsMu.RLock() - defer s.componentsMu.RUnlock() - for _, ready := range s.components { - if !ready { - return false - } - } - return true -} - -func (s *State) SetComponent(name string, ready bool) { - s.componentsMu.Lock() - s.components[name] = ready - s.componentsMu.Unlock() -} diff --git a/internal/identity/biz/model.go b/internal/identity/biz/model.go new file mode 100644 index 0000000..6020824 --- /dev/null +++ b/internal/identity/biz/model.go @@ -0,0 +1,54 @@ +package biz + +import ( + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `json:"id"` + DisplayName string `json:"displayName"` + Username string `json:"username"` + Email string `json:"email"` + Status string `json:"status"` +} + +type Workspace struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + ReportRetentionDays int32 `json:"reportRetentionDays"` + CreatedBy uuid.UUID `json:"createdBy"` + Role string `json:"role"` +} + +type WorkspaceUpdate struct { + Name *string + ReportRetentionDays *int32 +} + +type Member struct { + UserID uuid.UUID `json:"userId"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + CreatedAt time.Time `json:"createdAt"` +} + +type Invitation struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspaceId"` + Email string `json:"email"` + Token string `json:"token,omitempty"` + ExpiresAt time.Time `json:"expiresAt"` + AcceptedAt *time.Time `json:"acceptedAt,omitempty"` + RevokedAt *time.Time `json:"revokedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type Membership struct { + Workspace Workspace + Role string +} diff --git a/internal/identity/biz/provider.go b/internal/identity/biz/provider.go new file mode 100644 index 0000000..c226b4d --- /dev/null +++ b/internal/identity/biz/provider.go @@ -0,0 +1,259 @@ +package biz + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/platform/secret" + "github.com/google/uuid" +) + +type ProviderConnection struct { + ID uuid.UUID `json:"id"` + ProviderType string `json:"providerType"` + BaseURL string `json:"baseUrl"` + ProviderAccountID string `json:"providerAccountId,omitempty"` + Login string `json:"login,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Scopes []string `json:"scopes"` + IsDefault bool `json:"isDefault"` + Status string `json:"status"` + CredentialVersion int64 `json:"credentialVersion"` + LastValidatedAt *time.Time `json:"lastValidatedAt,omitempty"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + LastErrorCode string `json:"lastErrorCode,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ProviderCredential struct { + ConnectionID uuid.UUID + UserID uuid.UUID + ProviderType string + BaseURL string + Token []byte + CredentialVersion int64 +} + +type ProviderProfile struct { + AccountID string + Login string + Name string + Scopes []string +} + +type ProviderStore interface { + ListProviderConnections(context.Context, uuid.UUID, pagination.Request) (pagination.Page[ProviderConnection], error) + CreateProviderConnection(context.Context, uuid.UUID, string, string, []byte, []byte, int, ProviderProfile) (ProviderConnection, error) + GetProviderSecret(context.Context, uuid.UUID, uuid.UUID) (ProviderConnection, []byte, []byte, int, error) + MarkProviderConnectionUsed(context.Context, uuid.UUID, uuid.UUID) error + ReplaceProviderCredential(context.Context, uuid.UUID, uuid.UUID, []byte, []byte, int, ProviderProfile) (ProviderConnection, error) + SetProviderValidation(context.Context, uuid.UUID, uuid.UUID, ProviderProfile, string, string) (ProviderConnection, error) + SetDefaultProviderConnection(context.Context, uuid.UUID, uuid.UUID) (ProviderConnection, error) + RevokeProviderConnection(context.Context, uuid.UUID, uuid.UUID) (bool, error) +} + +type ProviderValidator interface { + Validate(context.Context, string, string, string) (ProviderProfile, error) +} + +type ProviderService struct { + store ProviderStore + cipher secret.Cipher + validator ProviderValidator +} + +func NewProviderService(store ProviderStore, cipher secret.Cipher, validator ProviderValidator) *ProviderService { + return &ProviderService{store: store, cipher: cipher, validator: validator} +} + +func (s *ProviderService) List(ctx context.Context, userID uuid.UUID, page pagination.Request) (pagination.Page[ProviderConnection], error) { + return s.store.ListProviderConnections(ctx, userID, page) +} + +func (s *ProviderService) Get(ctx context.Context, userID, connectionID uuid.UUID) (ProviderConnection, error) { + connection, _, _, _, err := s.store.GetProviderSecret(ctx, userID, connectionID) + + return connection, err +} + +func (s *ProviderService) Create(ctx context.Context, userID uuid.UUID, providerType, baseURL, token string) (ProviderConnection, error) { + providerType, baseURL, token, err := normalizeProviderInput(providerType, baseURL, token) + if err != nil { + return ProviderConnection{}, err + } + profile, err := s.validator.Validate(ctx, providerType, baseURL, token) + if err != nil { + return ProviderConnection{}, fault.Wrap(fault.Invalid, "provider.credential_invalid", "Provider credential could not be validated", err) + } + ciphertext, nonce, keyVersion, err := s.cipher.Encrypt([]byte(token)) + if err != nil { + return ProviderConnection{}, err + } + + return s.store.CreateProviderConnection(ctx, userID, providerType, baseURL, ciphertext, nonce, keyVersion, profile) +} + +func (s *ProviderService) ReplaceToken(ctx context.Context, userID, connectionID uuid.UUID, token string) (ProviderConnection, error) { + connection, _, _, _, err := s.store.GetProviderSecret(ctx, userID, connectionID) + if err != nil { + return ProviderConnection{}, err + } + token = strings.TrimSpace(token) + if token == "" { + return ProviderConnection{}, fault.New(fault.Invalid, "provider.token_required", "Token is required") + } + profile, err := s.validator.Validate(ctx, connection.ProviderType, connection.BaseURL, token) + if err != nil { + return ProviderConnection{}, fault.Wrap(fault.Invalid, "provider.credential_invalid", "Provider credential could not be validated", err) + } + ciphertext, nonce, keyVersion, err := s.cipher.Encrypt([]byte(token)) + if err != nil { + return ProviderConnection{}, err + } + + return s.store.ReplaceProviderCredential(ctx, userID, connectionID, ciphertext, nonce, keyVersion, profile) +} + +func (s *ProviderService) Test(ctx context.Context, userID, connectionID uuid.UUID) (ProviderConnection, error) { + connection, token, err := s.Credential(ctx, userID, connectionID, 0) + if err != nil { + return ProviderConnection{}, err + } + defer clear(token.Token) + profile, validationErr := s.validator.Validate(ctx, connection.ProviderType, connection.BaseURL, string(token.Token)) + status, code := "active", "" + if validationErr != nil { + status, code = "invalid", "provider.authentication_failed" + } + updated, err := s.store.SetProviderValidation(ctx, userID, connectionID, profile, status, code) + if err != nil { + return ProviderConnection{}, err + } + if validationErr != nil { + return updated, fault.Wrap(fault.Invalid, "provider.credential_invalid", "Provider credential could not be validated", validationErr) + } + + return updated, nil +} + +func (s *ProviderService) Credential(ctx context.Context, userID, connectionID uuid.UUID, requiredVersion int64) (ProviderConnection, ProviderCredential, error) { + connection, ciphertext, nonce, keyVersion, err := s.store.GetProviderSecret(ctx, userID, connectionID) + if err != nil { + return ProviderConnection{}, ProviderCredential{}, err + } + if requiredVersion != 0 && connection.CredentialVersion != requiredVersion { + return ProviderConnection{}, ProviderCredential{}, fault.New(fault.Conflict, "provider.credential_stale", "Provider credential changed after the operation was requested") + } + plaintext, err := s.cipher.Decrypt(ciphertext, nonce, keyVersion) + if err != nil { + return ProviderConnection{}, ProviderCredential{}, err + } + if err := s.store.MarkProviderConnectionUsed(ctx, userID, connection.ID); err != nil { + clear(plaintext) + return ProviderConnection{}, ProviderCredential{}, err + } + + return connection, ProviderCredential{ConnectionID: connection.ID, UserID: userID, ProviderType: connection.ProviderType, BaseURL: connection.BaseURL, Token: plaintext, CredentialVersion: connection.CredentialVersion}, nil +} + +func (s *ProviderService) SetDefault(ctx context.Context, userID, connectionID uuid.UUID) (ProviderConnection, error) { + return s.store.SetDefaultProviderConnection(ctx, userID, connectionID) +} + +func (s *ProviderService) Delete(ctx context.Context, userID, connectionID uuid.UUID) error { + ok, err := s.store.RevokeProviderConnection(ctx, userID, connectionID) + if err != nil { + return err + } + if !ok { + return fault.New(fault.NotFound, "provider.connection_not_found", "Provider connection not found") + } + + return nil +} + +func normalizeProviderInput(providerType, baseURL, token string) (string, string, string, error) { + providerType = strings.ToLower(strings.TrimSpace(providerType)) + if providerType != "github" && providerType != "gitlab" { + return "", "", "", fault.New(fault.Invalid, "provider.type_invalid", "Provider type must be github or gitlab") + } + switch providerType { + case "github": + baseURL = "https://github.com" + case "gitlab": + baseURL = "https://gitlab.com" + } + token = strings.TrimSpace(token) + if token == "" { + return "", "", "", fault.New(fault.Invalid, "provider.token_required", "Token is required") + } + + return providerType, baseURL, token, nil +} + +type HTTPProviderValidator struct{ client *http.Client } + +func NewHTTPProviderValidator(client *http.Client) *HTTPProviderValidator { + if client == nil { + client = &http.Client{Timeout: 15 * time.Second} + } + return &HTTPProviderValidator{client: client} +} + +func (v *HTTPProviderValidator) Validate(ctx context.Context, providerType, baseURL, token string) (ProviderProfile, error) { + endpoint := strings.TrimRight(baseURL, "/") + "/api/v4/user" + if providerType == "github" { + if baseURL == "https://github.com" { + endpoint = "https://api.github.com/user" + } else { + endpoint = strings.TrimRight(baseURL, "/") + "/api/v3/user" + } + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return ProviderProfile{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("User-Agent", "MoonCode") + response, err := v.client.Do(request) + if err != nil { + return ProviderProfile{}, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return ProviderProfile{}, fmt.Errorf("provider returned HTTP %d", response.StatusCode) + } + var body struct { + ID any `json:"id"` + Login string `json:"login"` + Username string `json:"username"` + Name string `json:"name"` + } + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + return ProviderProfile{}, err + } + login := body.Login + if login == "" { + login = body.Username + } + scopesHeader := response.Header.Get("X-OAuth-Scopes") + if providerType == "gitlab" { + scopesHeader = response.Header.Get("X-OAuth-Scopes") + } + var scopes []string + for _, scope := range strings.Split(scopesHeader, ",") { + if value := strings.TrimSpace(scope); value != "" { + scopes = append(scopes, value) + } + } + + return ProviderProfile{AccountID: fmt.Sprint(body.ID), Login: login, Name: body.Name, Scopes: scopes}, nil +} diff --git a/internal/identity/biz/provider_test.go b/internal/identity/biz/provider_test.go new file mode 100644 index 0000000..de9475b --- /dev/null +++ b/internal/identity/biz/provider_test.go @@ -0,0 +1,30 @@ +package biz + +import "testing" + +func TestNormalizeProviderInputUsesOfficialHosts(t *testing.T) { + tests := []struct { + provider string + inputURL string + wantURL string + }{ + {provider: "github", inputURL: "https://attacker.invalid", wantURL: "https://github.com"}, + {provider: "gitlab", inputURL: "https://127.0.0.1", wantURL: "https://gitlab.com"}, + } + + for _, test := range tests { + provider, baseURL, token, err := normalizeProviderInput(test.provider, test.inputURL, "secret") + if err != nil { + t.Fatal(err) + } + if provider != test.provider || baseURL != test.wantURL || token != "secret" { + t.Fatalf("unexpected normalized provider: %q %q %q", provider, baseURL, token) + } + } +} + +func TestNormalizeProviderInputRejectsMissingToken(t *testing.T) { + if _, _, _, err := normalizeProviderInput("github", "", " "); err == nil { + t.Fatal("expected missing token to be rejected") + } +} diff --git a/internal/identity/biz/service.go b/internal/identity/biz/service.go new file mode 100644 index 0000000..126511b --- /dev/null +++ b/internal/identity/biz/service.go @@ -0,0 +1,228 @@ +package biz + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "regexp" + "strings" + "time" + + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" +) + +var ErrForbidden = fault.New(fault.Forbidden, "workspace.forbidden", "Workspace permission denied") + +type Option func(*Service) + +type Service struct { + store Store + issuer string + now func() time.Time + random func([]byte) (int, error) +} + +func WithClock(now func() time.Time) Option { + return func(service *Service) { service.now = now } +} + +func NewService(store Store, issuer string, options ...Option) *Service { + service := &Service{store: store, issuer: issuer, now: time.Now, random: rand.Read} + for _, option := range options { + option(service) + } + + return service +} + +func (s *Service) Resolve(ctx context.Context, identity auth.Identity) (auth.Actor, error) { + user, err := s.store.ResolveIdentity(ctx, s.issuer, identity) + if err != nil { + return auth.Actor{}, err + } + + return auth.Actor{UserID: user.ID, Subject: identity.Subject, DisplayName: user.DisplayName, Username: user.Username, Email: user.Email, Status: user.Status}, nil +} + +func (s *Service) Session(ctx context.Context, actor auth.Actor) (User, []Workspace, error) { + user, err := s.store.GetUser(ctx, actor.UserID) + if err != nil { + return User{}, nil, err + } + workspaces, err := s.store.ListWorkspaces(ctx, actor.UserID) + if err != nil { + return User{}, nil, err + } + + return user, workspaces, nil +} + +func (s *Service) CompleteOnboarding(ctx context.Context, actor auth.Actor) error { + if _, err := s.store.ActivateUser(ctx, actor.UserID); err != nil { + return err + } + workspaces, err := s.store.ListWorkspaces(ctx, actor.UserID) + if err != nil { + return err + } + if len(workspaces) > 0 { + return nil + } + + name := actor.DisplayName + if name == "" { + name = actor.Username + } + if name == "" { + name = "My Workspace" + } else { + name += "'s Workspace" + } + _, err = s.store.CreateWorkspace(ctx, actor.UserID, name, uniqueSlug(name)) + + return err +} + +func (s *Service) CreateWorkspace(ctx context.Context, actor auth.Actor, name string) (Workspace, error) { + name = strings.TrimSpace(name) + if name == "" { + return Workspace{}, fault.New(fault.Invalid, "workspace.name_required", "Workspace name is required") + } + + return s.store.CreateWorkspace(ctx, actor.UserID, name, uniqueSlug(name)) +} + +func (s *Service) Membership(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, minimum string) (Membership, error) { + membership, err := s.store.GetMembership(ctx, workspaceID, actor.UserID) + if err != nil { + return Membership{}, ErrForbidden + } + levels := map[string]int{"member": 1, "admin": 2, "owner": 3} + if levels[membership.Role] < levels[minimum] { + return Membership{}, ErrForbidden + } + + return membership, nil +} + +func (s *Service) UpdateWorkspace(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, update WorkspaceUpdate) (Workspace, error) { + membership, err := s.Membership(ctx, actor, workspaceID, "admin") + if err != nil { + return Workspace{}, err + } + if update.Name == nil && update.ReportRetentionDays == nil { + return Workspace{}, fault.New(fault.Invalid, "workspace.update_required", "At least one workspace setting is required") + } + if update.Name != nil { + name := strings.TrimSpace(*update.Name) + if name == "" { + return Workspace{}, fault.New(fault.Invalid, "workspace.name_required", "Workspace name is required") + } + update.Name = &name + } + if update.ReportRetentionDays != nil && (*update.ReportRetentionDays < 1 || *update.ReportRetentionDays > 3650) { + return Workspace{}, fault.New(fault.Invalid, "workspace.report_retention_invalid", "Report retention must be between 1 and 3650 days") + } + + workspace, err := s.store.UpdateWorkspace(ctx, workspaceID, update) + workspace.Role = membership.Role + + return workspace, err +} + +func (s *Service) Members(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[Member], error) { + if _, err := s.Membership(ctx, actor, workspaceID, "member"); err != nil { + return pagination.Page[Member]{}, err + } + + return s.store.ListMembers(ctx, workspaceID, page) +} + +func (s *Service) RemoveMember(ctx context.Context, actor auth.Actor, workspaceID, userID uuid.UUID) error { + if _, err := s.Membership(ctx, actor, workspaceID, "admin"); err != nil { + return err + } + removed, err := s.store.RemoveMember(ctx, workspaceID, userID) + if err != nil { + return err + } + if !removed { + return fault.New(fault.Conflict, "workspace.member_not_removable", "Member cannot be removed") + } + + return nil +} + +func (s *Service) Invite(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, email string) (Invitation, error) { + if _, err := s.Membership(ctx, actor, workspaceID, "admin"); err != nil { + return Invitation{}, err + } + email = strings.ToLower(strings.TrimSpace(email)) + if !strings.Contains(email, "@") { + return Invitation{}, fault.New(fault.Invalid, "workspace.invitation_email_invalid", "A valid email is required") + } + raw := make([]byte, 32) + if _, err := s.random(raw); err != nil { + return Invitation{}, fmt.Errorf("create invitation token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + hash := sha256.Sum256([]byte(token)) + + invitation, err := s.store.CreateInvitation(ctx, workspaceID, actor.UserID, email, hash[:], s.now().Add(7*24*time.Hour)) + if err != nil { + return Invitation{}, err + } + invitation.Token = token + + return invitation, nil +} + +func (s *Service) Invitations(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[Invitation], error) { + if _, err := s.Membership(ctx, actor, workspaceID, "admin"); err != nil { + return pagination.Page[Invitation]{}, err + } + + return s.store.ListInvitations(ctx, workspaceID, page) +} + +func (s *Service) RevokeInvitation(ctx context.Context, actor auth.Actor, workspaceID, invitationID uuid.UUID) error { + if _, err := s.Membership(ctx, actor, workspaceID, "admin"); err != nil { + return err + } + ok, err := s.store.RevokeInvitation(ctx, workspaceID, invitationID) + if err != nil { + return err + } + if !ok { + return fault.New(fault.NotFound, "workspace.invitation_not_found", "Invitation not found") + } + + return nil +} + +func (s *Service) AcceptInvitation(ctx context.Context, actor auth.Actor, token string) error { + hash := sha256.Sum256([]byte(token)) + _, err := s.store.AcceptInvitation(ctx, actor.UserID, hash[:], actor.Email) + if err != nil { + return err + } + _, err = s.store.ActivateUser(ctx, actor.UserID) + + return err +} + +var slugCleaner = regexp.MustCompile(`[^a-z0-9]+`) + +func uniqueSlug(name string) string { + base := strings.Trim(slugCleaner.ReplaceAllString(strings.ToLower(name), "-"), "-") + if base == "" { + base = "workspace" + } + + return fmt.Sprintf("%s-%s", base, strings.ToLower(uuid.NewString()[:8])) +} diff --git a/internal/identity/biz/store.go b/internal/identity/biz/store.go new file mode 100644 index 0000000..40d53c7 --- /dev/null +++ b/internal/identity/biz/store.go @@ -0,0 +1,26 @@ +package biz + +import ( + "context" + "time" + + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" +) + +type Store interface { + ResolveIdentity(context.Context, string, auth.Identity) (User, error) + ActivateUser(context.Context, uuid.UUID) (User, error) + GetUser(context.Context, uuid.UUID) (User, error) + ListWorkspaces(context.Context, uuid.UUID) ([]Workspace, error) + CreateWorkspace(context.Context, uuid.UUID, string, string) (Workspace, error) + GetMembership(context.Context, uuid.UUID, uuid.UUID) (Membership, error) + UpdateWorkspace(context.Context, uuid.UUID, WorkspaceUpdate) (Workspace, error) + ListMembers(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Member], error) + RemoveMember(context.Context, uuid.UUID, uuid.UUID) (bool, error) + CreateInvitation(context.Context, uuid.UUID, uuid.UUID, string, []byte, time.Time) (Invitation, error) + ListInvitations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Invitation], error) + RevokeInvitation(context.Context, uuid.UUID, uuid.UUID) (bool, error) + AcceptInvitation(context.Context, uuid.UUID, []byte, string) (Workspace, error) +} diff --git a/internal/identity/context.go b/internal/identity/context.go deleted file mode 100644 index f50a383..0000000 --- a/internal/identity/context.go +++ /dev/null @@ -1,18 +0,0 @@ -package identity - -import ( - "context" - - "github.com/mooncode-ai/mooncode/internal/model" -) - -type principalKey struct{} - -func WithPrincipal(ctx context.Context, principal model.Principal) context.Context { - return context.WithValue(ctx, principalKey{}, principal) -} - -func PrincipalFromContext(ctx context.Context) (model.Principal, bool) { - principal, ok := ctx.Value(principalKey{}).(model.Principal) - return principal, ok -} diff --git a/internal/identity/data/membership_integration_test.go b/internal/identity/data/membership_integration_test.go new file mode 100644 index 0000000..8ed0aa1 --- /dev/null +++ b/internal/identity/data/membership_integration_test.go @@ -0,0 +1,56 @@ +//go:build integration + +package data + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func TestMembershipRequiresActiveUser(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + userID, workspaceID := uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Membership',$2,$3)`, workspaceID, "membership-"+workspaceID.String(), userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspace_members (workspace_id,user_id,role) VALUES ($1,$2,'member')`, workspaceID, userID); err != nil { + t.Fatal(err) + } + + store := NewStore(pool) + membership, err := store.GetMembership(ctx, workspaceID, userID) + if err != nil { + t.Fatal(err) + } + if membership.Role != "member" || membership.Workspace.Role != "member" { + t.Fatalf("membership roles = %q/%q, want member/member", membership.Role, membership.Workspace.Role) + } + workspaces, err := store.ListWorkspaces(ctx, userID) + if err != nil { + t.Fatal(err) + } + if len(workspaces) != 1 || workspaces[0].Role != "member" { + t.Fatalf("workspaces = %+v, want one member workspace", workspaces) + } + if _, err := pool.Exec(ctx, `UPDATE users SET status='suspended' WHERE id=$1`, userID); err != nil { + t.Fatal(err) + } + if _, err := store.GetMembership(ctx, workspaceID, userID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("suspended membership error = %v, want pgx.ErrNoRows", err) + } +} diff --git a/internal/identity/data/provider_integration_test.go b/internal/identity/data/provider_integration_test.go new file mode 100644 index 0000000..db7a79d --- /dev/null +++ b/internal/identity/data/provider_integration_test.go @@ -0,0 +1,151 @@ +//go:build integration + +package data + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/platform/secret" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type fixedValidator struct{} + +func (fixedValidator) Validate(context.Context, string, string, string) (identity.ProviderProfile, error) { + return identity.ProviderProfile{AccountID: "1", Login: "octocat", Name: "Octocat", Scopes: []string{"repo"}}, nil +} + +func TestProviderConnectionIsPersonalAndEncrypted(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + store := NewStore(pool) + cipher, err := secret.NewAESGCM([]byte("01234567890123456789012345678901"), 1) + if err != nil { + t.Fatal(err) + } + service := identity.NewProviderService(store, cipher, fixedValidator{}) + first, err := store.ResolveIdentity(ctx, "integration", auth.Identity{Subject: uuid.NewString(), Username: "first"}) + if err != nil { + t.Fatal(err) + } + second, err := store.ResolveIdentity(ctx, "integration", auth.Identity{Subject: uuid.NewString(), Username: "second"}) + if err != nil { + t.Fatal(err) + } + token := "github_pat_integration_secret" + connection, err := service.Create(ctx, first.ID, "github", "https://github.com", token) + if err != nil { + t.Fatal(err) + } + if _, err := service.Get(ctx, second.ID, connection.ID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("second user unexpectedly accessed connection: %v", err) + } + if _, err := service.Get(ctx, first.ID, connection.ID); err != nil { + t.Fatal(err) + } + var usedBeforeCredential bool + if err := pool.QueryRow(ctx, `SELECT last_used_at IS NOT NULL FROM provider_connections WHERE id=$1`, connection.ID).Scan(&usedBeforeCredential); err != nil { + t.Fatal(err) + } + if usedBeforeCredential { + t.Fatal("local provider connection lookup marked the PAT as used") + } + _, credential, err := service.Credential(ctx, first.ID, connection.ID, connection.CredentialVersion) + if err != nil { + t.Fatal(err) + } + if string(credential.Token) != token { + t.Fatal("decrypted provider credential did not match the stored PAT") + } + clear(credential.Token) + var usedAfterCredential bool + if err := pool.QueryRow(ctx, `SELECT last_used_at IS NOT NULL FROM provider_connections WHERE id=$1`, connection.ID).Scan(&usedAfterCredential); err != nil { + t.Fatal(err) + } + if !usedAfterCredential { + t.Fatal("remote credential use did not update last_used_at") + } + replacementToken := "github_pat_integration_replacement_secret" + connection, err = service.ReplaceToken(ctx, first.ID, connection.ID, replacementToken) + if err != nil { + t.Fatal(err) + } + if _, err := service.SetDefault(ctx, first.ID, connection.ID); err != nil { + t.Fatal(err) + } + var ciphertext []byte + if err := pool.QueryRow(ctx, "SELECT token_ciphertext FROM provider_connections WHERE id=$1", connection.ID).Scan(&ciphertext); err != nil { + t.Fatal(err) + } + if strings.Contains(string(ciphertext), token) || strings.Contains(string(ciphertext), replacementToken) { + t.Fatal("PAT was stored in plaintext") + } + items, err := service.List(ctx, first.ID, pagination.Request{Limit: 50}) + if err != nil || len(items.Items) != 1 { + t.Fatalf("list: %v %#v", err, items) + } + if err := service.Delete(ctx, first.ID, connection.ID); err != nil { + t.Fatal(err) + } + assertProviderAudit(t, ctx, pool, first.ID, connection.ID, token, replacementToken) + if _, err := service.Create(ctx, first.ID, "github", "https://github.com", token); err != nil { + t.Fatalf("recreate revoked connection: %v", err) + } +} + +func assertProviderAudit(t *testing.T, ctx context.Context, pool interface { + Query(context.Context, string, ...any) (pgx.Rows, error) +}, actorID, connectionID uuid.UUID, secrets ...string) { + t.Helper() + + wantActions := map[string]int{ + audit.ActionProviderConnectionCreated: 1, + audit.ActionProviderCredentialRotated: 1, + audit.ActionProviderConnectionDefaultSet: 1, + audit.ActionProviderConnectionRevoked: 1, + } + rows, err := pool.Query(ctx, `SELECT workspace_id,actor_user_id,action,metadata::text FROM audit_events WHERE resource_type=$1 AND resource_id=$2`, audit.ResourceProviderConnection, connectionID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + seen := make(map[string]int) + for rows.Next() { + var workspaceID, eventActorID uuid.NullUUID + var action, metadata string + if err := rows.Scan(&workspaceID, &eventActorID, &action, &metadata); err != nil { + t.Fatal(err) + } + if workspaceID.Valid || !eventActorID.Valid || eventActorID.UUID != actorID { + t.Fatalf("provider audit identity = (%+v, %+v), want (NULL, %s)", workspaceID, eventActorID, actorID) + } + for _, secret := range secrets { + if strings.Contains(metadata, secret) { + t.Fatalf("provider audit metadata leaked PAT: %s", metadata) + } + } + seen[action]++ + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + for action, count := range wantActions { + if seen[action] != count { + t.Fatalf("audit action %q count = %d, want %d; all=%v", action, seen[action], count, seen) + } + } +} diff --git a/internal/identity/data/provider_store.go b/internal/identity/data/provider_store.go new file mode 100644 index 0000000..b0e1e85 --- /dev/null +++ b/internal/identity/data/provider_store.go @@ -0,0 +1,210 @@ +package data + +import ( + "context" + "time" + + "github.com/fuchencong/mooncode/internal/data/pagecursor" + "github.com/fuchencong/mooncode/internal/data/sqlc" + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) ListProviderConnections(ctx context.Context, userID uuid.UUID, page pagination.Request) (pagination.Page[identity.ProviderConnection], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListProviderConnections(ctx, sqlc.ListProviderConnectionsParams{ + UserID: userID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[identity.ProviderConnection]{}, err + } + items := make([]identity.ProviderConnection, 0, len(rows)) + for _, row := range rows { + items = append(items, identity.ProviderConnection{ + ID: row.ID, ProviderType: row.ProviderType, BaseURL: row.BaseUrl, + ProviderAccountID: row.ProviderAccountID.String, Login: row.Login.String, DisplayName: row.DisplayName.String, + Scopes: row.Scopes, IsDefault: row.IsDefault, Status: row.Status, CredentialVersion: row.CredentialVersion, + LastValidatedAt: optionalTime(row.LastValidatedAt), LastUsedAt: optionalTime(row.LastUsedAt), LastErrorCode: row.LastErrorCode.String, + CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, + }) + } + + return pagination.Build(items, page.Limit, func(item identity.ProviderConnection) (time.Time, uuid.UUID) { + return item.CreatedAt, item.ID + }), nil +} + +func (s *Store) CreateProviderConnection(ctx context.Context, userID uuid.UUID, providerType, baseURL string, ciphertext, nonce []byte, keyVersion int, profile identity.ProviderProfile) (identity.ProviderConnection, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return identity.ProviderConnection{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + var count int + if err := tx.QueryRow(ctx, `SELECT count(*) FROM provider_connections WHERE user_id=$1 AND provider_type=$2 AND status <> 'revoked'`, userID, providerType).Scan(&count); err != nil { + return identity.ProviderConnection{}, err + } + row, err := q.CreateProviderConnection(ctx, sqlc.CreateProviderConnectionParams{ + ID: uuid.New(), UserID: userID, ProviderType: providerType, BaseUrl: baseURL, + TokenCiphertext: ciphertext, TokenNonce: nonce, KeyVersion: int32(keyVersion), + ProviderAccountID: text(profile.AccountID), Login: text(profile.Login), DisplayName: text(profile.Name), Scopes: profile.Scopes, IsDefault: count == 0, + }) + if err != nil { + return identity.ProviderConnection{}, err + } + if err = audit.Record(ctx, q, audit.Event{ + ActorUserID: userID, + Action: audit.ActionProviderConnectionCreated, + Resource: audit.ResourceProviderConnection, + ResourceID: row.ID, + Metadata: map[string]any{ + "providerType": row.ProviderType, + "isDefault": row.IsDefault, + }, + }); err != nil { + return identity.ProviderConnection{}, err + } + err = tx.Commit(ctx) + + return mapProvider(row), err +} + +func (s *Store) GetProviderSecret(ctx context.Context, userID, connectionID uuid.UUID) (identity.ProviderConnection, []byte, []byte, int, error) { + row, err := s.queries.GetProviderConnection(ctx, sqlc.GetProviderConnectionParams{ID: connectionID, UserID: userID}) + return mapProvider(row), row.TokenCiphertext, row.TokenNonce, int(row.KeyVersion), err +} + +func (s *Store) MarkProviderConnectionUsed(ctx context.Context, userID, connectionID uuid.UUID) error { + count, err := s.queries.MarkProviderConnectionUsed(ctx, sqlc.MarkProviderConnectionUsedParams{ID: connectionID, UserID: userID}) + if err == nil && count == 0 { + return fault.New(fault.Conflict, "provider.connection_unavailable", "Provider connection is no longer available") + } + + return err +} + +func (s *Store) ReplaceProviderCredential(ctx context.Context, userID, connectionID uuid.UUID, ciphertext, nonce []byte, keyVersion int, profile identity.ProviderProfile) (identity.ProviderConnection, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return identity.ProviderConnection{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + _, err = q.ReplaceProviderCredential(ctx, sqlc.ReplaceProviderCredentialParams{ID: connectionID, UserID: userID, TokenCiphertext: ciphertext, TokenNonce: nonce, KeyVersion: int32(keyVersion)}) + if err != nil { + return identity.ProviderConnection{}, err + } + row, err := q.SetProviderConnectionValidation(ctx, validationParams(connectionID, userID, profile, "active", "")) + if err != nil { + return identity.ProviderConnection{}, err + } + if err = audit.Record(ctx, q, audit.Event{ + ActorUserID: userID, + Action: audit.ActionProviderCredentialRotated, + Resource: audit.ResourceProviderConnection, + ResourceID: connectionID, + Metadata: map[string]any{ + "providerType": row.ProviderType, + "credentialVersion": row.CredentialVersion, + }, + }); err != nil { + return identity.ProviderConnection{}, err + } + err = tx.Commit(ctx) + + return mapProvider(row), err +} + +func (s *Store) SetProviderValidation(ctx context.Context, userID, connectionID uuid.UUID, profile identity.ProviderProfile, status, code string) (identity.ProviderConnection, error) { + row, err := s.queries.SetProviderConnectionValidation(ctx, validationParams(connectionID, userID, profile, status, code)) + return mapProvider(row), err +} + +func (s *Store) SetDefaultProviderConnection(ctx context.Context, userID, connectionID uuid.UUID) (identity.ProviderConnection, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return identity.ProviderConnection{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + connection, err := q.GetProviderConnection(ctx, sqlc.GetProviderConnectionParams{ID: connectionID, UserID: userID}) + if err != nil { + return identity.ProviderConnection{}, err + } + if err = q.ClearDefaultProviderConnections(ctx, sqlc.ClearDefaultProviderConnectionsParams{UserID: userID, ProviderType: connection.ProviderType}); err != nil { + return identity.ProviderConnection{}, err + } + connection, err = q.SetDefaultProviderConnection(ctx, sqlc.SetDefaultProviderConnectionParams{ID: connectionID, UserID: userID}) + if err != nil { + return identity.ProviderConnection{}, err + } + if err = audit.Record(ctx, q, audit.Event{ + ActorUserID: userID, + Action: audit.ActionProviderConnectionDefaultSet, + Resource: audit.ResourceProviderConnection, + ResourceID: connectionID, + Metadata: map[string]any{"providerType": connection.ProviderType}, + }); err != nil { + return identity.ProviderConnection{}, err + } + err = tx.Commit(ctx) + + return mapProvider(connection), err +} + +func (s *Store) RevokeProviderConnection(ctx context.Context, userID, connectionID uuid.UUID) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + count, err := q.RevokeProviderConnection(ctx, sqlc.RevokeProviderConnectionParams{ID: connectionID, UserID: userID}) + if err != nil || count == 0 { + return false, err + } + if err = audit.Record(ctx, q, audit.Event{ + ActorUserID: userID, + Action: audit.ActionProviderConnectionRevoked, + Resource: audit.ResourceProviderConnection, + ResourceID: connectionID, + }); err != nil { + return false, err + } + if err = tx.Commit(ctx); err != nil { + return false, err + } + + return true, nil +} + +func validationParams(id, userID uuid.UUID, profile identity.ProviderProfile, status, code string) sqlc.SetProviderConnectionValidationParams { + return sqlc.SetProviderConnectionValidationParams{ID: id, UserID: userID, ProviderAccountID: text(profile.AccountID), Login: text(profile.Login), DisplayName: text(profile.Name), Scopes: profile.Scopes, Status: status, LastErrorCode: text(code)} +} + +func mapProvider(row sqlc.ProviderConnection) identity.ProviderConnection { + return identity.ProviderConnection{ + ID: row.ID, ProviderType: row.ProviderType, BaseURL: row.BaseUrl, + ProviderAccountID: row.ProviderAccountID.String, Login: row.Login.String, DisplayName: row.DisplayName.String, + Scopes: row.Scopes, IsDefault: row.IsDefault, Status: row.Status, CredentialVersion: row.CredentialVersion, + LastValidatedAt: optionalTime(row.LastValidatedAt), LastUsedAt: optionalTime(row.LastUsedAt), LastErrorCode: row.LastErrorCode.String, + CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, + } +} + +func text(value string) pgtype.Text { return pgtype.Text{String: value, Valid: value != ""} } +func optionalTime(value pgtype.Timestamptz) *time.Time { + if !value.Valid { + return nil + } + result := value.Time + return &result +} diff --git a/internal/identity/data/store.go b/internal/identity/data/store.go new file mode 100644 index 0000000..2f0da42 --- /dev/null +++ b/internal/identity/data/store.go @@ -0,0 +1,251 @@ +package data + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + "github.com/fuchencong/mooncode/internal/data/pagecursor" + "github.com/fuchencong/mooncode/internal/data/sqlc" + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool + queries *sqlc.Queries +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool, queries: sqlc.New(pool)} +} + +func (s *Store) ResolveIdentity(ctx context.Context, issuer string, source auth.Identity) (identity.User, error) { + params := sqlc.GetUserByIdentityParams{Issuer: issuer, Subject: source.Subject} + user, err := s.queries.GetUserByIdentity(ctx, params) + if err == nil { + user, err = s.queries.UpdateUserProfile(ctx, sqlc.UpdateUserProfileParams{ID: user.ID, DisplayName: source.DisplayName, Username: source.Username, Email: source.Email}) + return mapUser(user), err + } + if !errors.Is(err, pgx.ErrNoRows) { + return identity.User{}, err + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return identity.User{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + user, err = q.CreateUser(ctx, sqlc.CreateUserParams{ID: uuid.New(), DisplayName: source.DisplayName, Username: source.Username, Email: source.Email, Status: "pending"}) + if err == nil { + err = q.CreateOAuthIdentity(ctx, sqlc.CreateOAuthIdentityParams{Issuer: issuer, Subject: source.Subject, UserID: user.ID}) + } + if err == nil { + err = tx.Commit(ctx) + } + if err != nil { + if existing, lookupErr := s.queries.GetUserByIdentity(ctx, params); lookupErr == nil { + return mapUser(existing), nil + } + return identity.User{}, fmt.Errorf("create identity: %w", err) + } + + return mapUser(user), nil +} + +func (s *Store) ActivateUser(ctx context.Context, id uuid.UUID) (identity.User, error) { + user, err := s.queries.ActivateUser(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + user, err = s.queries.GetUser(ctx, id) + } + + return mapUser(user), err +} + +func (s *Store) GetUser(ctx context.Context, id uuid.UUID) (identity.User, error) { + user, err := s.queries.GetUser(ctx, id) + return mapUser(user), err +} + +func (s *Store) ListWorkspaces(ctx context.Context, userID uuid.UUID) ([]identity.Workspace, error) { + rows, err := s.queries.ListUserWorkspaces(ctx, userID) + if err != nil { + return nil, err + } + items := make([]identity.Workspace, 0, len(rows)) + for _, row := range rows { + items = append(items, identity.Workspace{ + ID: row.ID, Name: row.Name, Slug: row.Slug, ReportRetentionDays: row.ReportRetentionDays, + CreatedBy: row.CreatedBy, Role: row.Role, + }) + } + + return items, nil +} + +func (s *Store) CreateWorkspace(ctx context.Context, userID uuid.UUID, name, slug string) (identity.Workspace, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return identity.Workspace{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + workspace, err := q.CreateWorkspace(ctx, sqlc.CreateWorkspaceParams{ID: uuid.New(), Name: name, Slug: slug, CreatedBy: userID}) + if err == nil { + err = q.AddWorkspaceMember(ctx, sqlc.AddWorkspaceMemberParams{WorkspaceID: workspace.ID, UserID: userID, Role: "owner"}) + } + if err == nil { + err = q.CreateRetentionCleanupSchedule(ctx, workspace.ID) + } + if err == nil { + profileID := uuid.New() + _, err = q.CreateAnalysisProfile(ctx, sqlc.CreateAnalysisProfileParams{ + ID: profileID, WorkspaceID: workspace.ID, Name: "Code scale", CreatedBy: userID, + }) + if err == nil { + _, err = q.CreateAnalysisProfileVersion(ctx, sqlc.CreateAnalysisProfileVersionParams{ + ID: uuid.New(), WorkspaceID: workspace.ID, ProfileID: profileID, Version: 1, + DimensionKey: "code_scale", Definition: analysis.DefaultProfileDefinition(), CreatedBy: userID, + }) + } + } + if err == nil { + err = tx.Commit(ctx) + } + + item := mapWorkspace(workspace) + item.Role = "owner" + + return item, err +} + +func (s *Store) GetMembership(ctx context.Context, workspaceID, userID uuid.UUID) (identity.Membership, error) { + row, err := s.queries.GetWorkspaceMembership(ctx, sqlc.GetWorkspaceMembershipParams{ID: workspaceID, UserID: userID}) + return identity.Membership{Workspace: identity.Workspace{ID: row.ID, Name: row.Name, Slug: row.Slug, ReportRetentionDays: row.ReportRetentionDays, CreatedBy: row.CreatedBy, Role: row.Role}, Role: row.Role}, err +} + +func (s *Store) UpdateWorkspace(ctx context.Context, id uuid.UUID, update identity.WorkspaceUpdate) (identity.Workspace, error) { + params := sqlc.UpdateWorkspaceParams{ID: id} + if update.Name != nil { + params.Name = pgtype.Text{String: *update.Name, Valid: true} + } + if update.ReportRetentionDays != nil { + params.ReportRetentionDays = pgtype.Int4{Int32: *update.ReportRetentionDays, Valid: true} + } + row, err := s.queries.UpdateWorkspace(ctx, params) + return mapWorkspace(row), err +} + +func (s *Store) ListMembers(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[identity.Member], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListWorkspaceMembers(ctx, sqlc.ListWorkspaceMembersParams{ + WorkspaceID: workspaceID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[identity.Member]{}, err + } + items := make([]identity.Member, 0, len(rows)) + for _, row := range rows { + items = append(items, identity.Member{UserID: row.ID, Username: row.Username, Email: row.Email, DisplayName: row.DisplayName, Role: row.Role, CreatedAt: row.CreatedAt.Time}) + } + + return pagination.Build(items, page.Limit, func(item identity.Member) (time.Time, uuid.UUID) { + return item.CreatedAt, item.UserID + }), nil +} + +func (s *Store) RemoveMember(ctx context.Context, workspaceID, userID uuid.UUID) (bool, error) { + count, err := s.queries.RemoveWorkspaceMember(ctx, sqlc.RemoveWorkspaceMemberParams{WorkspaceID: workspaceID, UserID: userID}) + return count > 0, err +} + +func (s *Store) CreateInvitation(ctx context.Context, workspaceID, actorID uuid.UUID, email string, hash []byte, expiresAt time.Time) (identity.Invitation, error) { + row, err := s.queries.CreateWorkspaceInvitation(ctx, sqlc.CreateWorkspaceInvitationParams{ID: uuid.New(), WorkspaceID: workspaceID, Email: email, TokenHash: hash, CreatedBy: actorID, ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}}) + return mapInvitation(row), err +} + +func (s *Store) ListInvitations(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[identity.Invitation], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListWorkspaceInvitations(ctx, sqlc.ListWorkspaceInvitationsParams{ + WorkspaceID: workspaceID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[identity.Invitation]{}, err + } + items := make([]identity.Invitation, 0, len(rows)) + for _, row := range rows { + items = append(items, mapInvitation(row)) + } + + return pagination.Build(items, page.Limit, func(item identity.Invitation) (time.Time, uuid.UUID) { + return item.CreatedAt, item.ID + }), nil +} + +func (s *Store) RevokeInvitation(ctx context.Context, workspaceID, invitationID uuid.UUID) (bool, error) { + count, err := s.queries.RevokeWorkspaceInvitation(ctx, sqlc.RevokeWorkspaceInvitationParams{ID: invitationID, WorkspaceID: workspaceID}) + return count > 0, err +} + +func (s *Store) AcceptInvitation(ctx context.Context, userID uuid.UUID, hash []byte, email string) (identity.Workspace, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return identity.Workspace{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + invitation, err := q.GetInvitationByTokenHash(ctx, hash) + if err != nil { + return identity.Workspace{}, err + } + if !strings.EqualFold(invitation.Email, email) { + return identity.Workspace{}, fault.New(fault.Forbidden, "workspace.invitation_recipient_mismatch", "Invitation does not belong to the authenticated user") + } + if err = q.AddWorkspaceMember(ctx, sqlc.AddWorkspaceMemberParams{WorkspaceID: invitation.WorkspaceID, UserID: userID, Role: "member"}); err != nil { + return identity.Workspace{}, err + } + if count, acceptErr := q.AcceptWorkspaceInvitation(ctx, invitation.ID); acceptErr != nil || count != 1 { + return identity.Workspace{}, fault.New(fault.Invalid, "workspace.invitation_unavailable", "Invitation is no longer available") + } + if err = tx.Commit(ctx); err != nil { + return identity.Workspace{}, err + } + membership, err := s.GetMembership(ctx, invitation.WorkspaceID, userID) + + return membership.Workspace, err +} + +func mapUser(row sqlc.User) identity.User { + return identity.User{ID: row.ID, DisplayName: row.DisplayName, Username: row.Username, Email: row.Email, Status: row.Status} +} +func mapWorkspace(row sqlc.Workspace) identity.Workspace { + return identity.Workspace{ID: row.ID, Name: row.Name, Slug: row.Slug, ReportRetentionDays: row.ReportRetentionDays, CreatedBy: row.CreatedBy} +} +func mapInvitation(row sqlc.WorkspaceInvitation) identity.Invitation { + item := identity.Invitation{ID: row.ID, WorkspaceID: row.WorkspaceID, Email: row.Email, ExpiresAt: row.ExpiresAt.Time, CreatedAt: row.CreatedAt.Time} + if row.AcceptedAt.Valid { + value := row.AcceptedAt.Time + item.AcceptedAt = &value + } + if row.RevokedAt.Valid { + value := row.RevokedAt.Time + item.RevokedAt = &value + } + return item +} diff --git a/internal/identity/headers.go b/internal/identity/headers.go deleted file mode 100644 index 0dd3aca..0000000 --- a/internal/identity/headers.go +++ /dev/null @@ -1,66 +0,0 @@ -package identity - -import ( - "errors" - "net/http" - "strings" - "unicode" - "unicode/utf8" - - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" -) - -const GatewayTokenHeader = "X-MoonCode-Gateway-Token" - -var ( - ErrInvalidHeaders = errors.New("identity: invalid ForwardAuth headers") - ErrSubjectRequired = errors.New("identity: OAuth subject is required") -) - -func ParseHeaders(header http.Header, cfg config.IdentityConfig) (model.ExternalIdentity, error) { - username, err := cleanHeader(header.Get("Remote-User"), true) - if err != nil { - return model.ExternalIdentity{}, err - } - displayName, err := cleanHeader(header.Get("Remote-Name"), false) - if err != nil { - return model.ExternalIdentity{}, err - } - email, err := cleanHeader(header.Get("Remote-Email"), false) - if err != nil { - return model.ExternalIdentity{}, err - } - subValue := header.Get("Remote-Sub") - if strings.TrimSpace(subValue) == "" { - return model.ExternalIdentity{}, ErrSubjectRequired - } - sub, err := cleanHeader(subValue, true) - if err != nil { - return model.ExternalIdentity{}, err - } - if len(sub) > 255 || strings.IndexFunc(sub, unicode.IsControl) >= 0 { - return model.ExternalIdentity{}, ErrInvalidHeaders - } - if displayName == "" { - displayName = username - } - return model.ExternalIdentity{ - Issuer: cfg.Issuer, - Subject: sub, - Username: username, - Email: email, - DisplayName: displayName, - }, nil -} - -func cleanHeader(value string, required bool) (string, error) { - value = strings.TrimSpace(value) - if required && value == "" { - return "", ErrInvalidHeaders - } - if len(value) > 512 || !utf8.ValidString(value) || strings.ContainsAny(value, "\r\n\x00") { - return "", ErrInvalidHeaders - } - return value, nil -} diff --git a/internal/identity/headers_test.go b/internal/identity/headers_test.go deleted file mode 100644 index c7da5d0..0000000 --- a/internal/identity/headers_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package identity - -import ( - "net/http" - "testing" - - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/stretchr/testify/require" -) - -func TestParseHeadersUsesOAuthSubject(t *testing.T) { - header := make(http.Header) - header.Set("Remote-User", "Moon") - header.Set("Remote-Name", "Moon User") - header.Set("Remote-Email", "moon@example.com") - header.Set("Remote-Sub", "subject-42") - - identity, err := ParseHeaders(header, config.IdentityConfig{Issuer: "https://github.com"}) - require.NoError(t, err) - require.Equal(t, "subject-42", identity.Subject) - require.Equal(t, "Moon", identity.Username) -} - -func TestParseHeadersRequiresOAuthSubject(t *testing.T) { - header := make(http.Header) - header.Set("Remote-User", "Moon") - - _, err := ParseHeaders(header, config.IdentityConfig{Issuer: "https://github.com"}) - require.ErrorIs(t, err, ErrSubjectRequired) -} - -func TestParseHeadersRejectsInjectedNewline(t *testing.T) { - header := make(http.Header) - header["Remote-User"] = []string{"moon\nadmin"} - - _, err := ParseHeaders(header, config.IdentityConfig{Issuer: "https://github.com"}) - require.ErrorIs(t, err, ErrInvalidHeaders) -} diff --git a/internal/jobs/git_sync.go b/internal/jobs/git_sync.go deleted file mode 100644 index fcb68ca..0000000 --- a/internal/jobs/git_sync.go +++ /dev/null @@ -1,118 +0,0 @@ -package jobs - -import ( - "context" - "encoding/json" - "errors" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/checkout" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/secretstore" - gitcore "github.com/mooncode-ai/mooncode/pkg/git" - "github.com/mooncode-ai/mooncode/pkg/scm" -) - -type GitSyncHandler struct { - metadata repository.MetadataStore - secrets secretstore.SecretStore - registry *scm.Registry - git gitcore.Client - checkouts checkout.Store - cfg config.Config -} - -type GitSyncOption func(*GitSyncHandler) - -func NewGitSyncHandler(metadata repository.MetadataStore, secrets secretstore.SecretStore, registry *scm.Registry, client gitcore.Client, checkouts checkout.Store, cfg config.Config, opts ...GitSyncOption) *GitSyncHandler { - handler := &GitSyncHandler{metadata: metadata, secrets: secrets, registry: registry, git: client, checkouts: checkouts, cfg: cfg} - for _, option := range opts { - if option != nil { - option(handler) - } - } - return handler -} -func (*GitSyncHandler) Type() string { return "repository.sync" } - -func (h *GitSyncHandler) Handle(ctx context.Context, job model.Job) (resultErr error) { - var payload model.RepositorySyncPayload - if err := json.Unmarshal(job.Payload, &payload); err != nil || payload.RepositoryID == uuid.Nil { - return Permanent("repository.invalid_job", errors.New("invalid repository sync payload")) - } - repo, err := h.metadata.GetRepository(ctx, job.WorkspaceID, payload.RepositoryID) - if err != nil { - return Permanent("repository.not_found", err) - } - if err := h.metadata.MarkRepositorySyncing(ctx, job, repo.ID); err != nil { - return Retryable("repository.state_failed", err) - } - defer func() { - if resultErr != nil { - _ = h.metadata.MarkSyncFailed(context.Background(), job, repo.ID, errorCode(resultErr), safeErrorMessage(resultErr)) - } - }() - - providerType := "generic" - credential := gitcore.Credential{} - if repo.ConnectionID != nil { - connection, err := h.metadata.GetSCMConnection(ctx, job.WorkspaceID, *repo.ConnectionID) - if err != nil { - return Permanent("repository.connection_missing", err) - } - providerType = connection.Type - if connection.SecretRef != nil { - values, err := h.secrets.Get(ctx, secretstore.SecretRef{ID: *connection.SecretRef, Scope: secretstore.Scope{WorkspaceID: job.WorkspaceID, ResourceType: "scm_connection", ResourceID: connection.ID}}) - if err != nil { - return Permanent("repository.credential_failed", err) - } - credential = gitcore.NewCredential(reveal(values, "username"), reveal(values, "token")) - } - } - provider, err := h.registry.Provider(providerType) - if err != nil { - return Permanent("repository.provider_unknown", err) - } - remote, err := provider.Validate(ctx, repo.CloneURL) - if err != nil { - return Permanent("repository.remote_rejected", err) - } - - var fetched gitcore.FetchResult - _, err = h.checkouts.Replace(ctx, repo.ID, func(ctx context.Context, directory string) error { - var fetchErr error - fetched, fetchErr = h.git.Fetch(ctx, gitcore.FetchRequest{ - Provider: providerType, Remote: remote.URL, Ref: repo.Ref, Directory: directory, - Credential: credential, Depth: h.cfg.Git.Depth, Timeout: h.cfg.Git.CloneTimeout, - }) - if fetchErr != nil { - return fetchErr - } - _, fetchErr = h.metadata.GetRepository(ctx, job.WorkspaceID, repo.ID) - return fetchErr - }) - if err != nil { - return Retryable("repository.fetch_failed", err) - } - if err := h.metadata.MarkSyncReady(ctx, job, repo.ID, fetched.CommitSHA); err != nil { - return Retryable("repository.commit_failed", err) - } - return nil -} -func reveal(values secretstore.SecretValues, key string) string { - if value, ok := values[key]; ok { - return value.Reveal() - } - return "" -} -func errorCode(err error) string { - var classified *Error - if errors.As(err, &classified) { - return classified.Code - } - return "repository.sync_failed" -} - -var _ Handler = (*GitSyncHandler)(nil) diff --git a/internal/jobs/runner.go b/internal/jobs/runner.go deleted file mode 100644 index b02fe5f..0000000 --- a/internal/jobs/runner.go +++ /dev/null @@ -1,307 +0,0 @@ -// Package jobs contains the in-process PostgreSQL-backed background runner. -package jobs - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "runtime/debug" - "sync/atomic" - "time" - - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/sanitize" - "github.com/rs/zerolog" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" - "go.opentelemetry.io/otel/trace/noop" -) - -type Handler interface { - Type() string - Handle(ctx context.Context, job model.Job) error -} - -type HandlerFunc func(context.Context, model.Job) error -type Middleware func(HandlerFunc) HandlerFunc - -// Chain applies middleware in declaration order: the first middleware is the -// outermost boundary. -func Chain(handler HandlerFunc, middleware ...Middleware) HandlerFunc { - for index := len(middleware) - 1; index >= 0; index-- { - if middleware[index] != nil { - handler = middleware[index](handler) - } - } - return handler -} - -type Error struct { - Code string - Retryable bool - Err error -} - -func (e *Error) Error() string { - if e.Err == nil { - return e.Code - } - return e.Err.Error() -} -func (e *Error) Unwrap() error { return e.Err } -func Retryable(code string, err error) error { return &Error{Code: code, Retryable: true, Err: err} } -func Permanent(code string, err error) error { return &Error{Code: code, Retryable: false, Err: err} } - -type RunnerOption func(*Runner) error - -type Observer interface { - ObserveJob(jobType, result string, seconds float64) - SetJobQueueDepth(jobType, status string, depth float64) -} - -func WithHandler(handler Handler) RunnerOption { - return func(r *Runner) error { - if handler == nil || handler.Type() == "" { - return errors.New("job handler type is required") - } - if _, exists := r.handlers[handler.Type()]; exists { - return fmt.Errorf("duplicate job handler %q", handler.Type()) - } - r.handlers[handler.Type()] = handler - return nil - } -} -func WithRunnerClock(clock func() time.Time) RunnerOption { - return func(r *Runner) error { - if clock != nil { - r.clock = clock - } - return nil - } -} - -func WithObserver(observer Observer) RunnerOption { - return func(r *Runner) error { - r.observer = observer - return nil - } -} - -func WithTracing(provider trace.TracerProvider, propagator propagation.TextMapPropagator) RunnerOption { - return func(r *Runner) error { - if provider != nil { - r.tracer = provider.Tracer("github.com/mooncode-ai/mooncode/internal/jobs") - } - if propagator != nil { - r.propagator = propagator - } - return nil - } -} - -func WithMiddleware(middleware ...Middleware) RunnerOption { - return func(r *Runner) error { - r.middleware = append(r.middleware, middleware...) - return nil - } -} - -type Runner struct { - store repository.JobStore - owner string - cfg config.JobsConfig - logger zerolog.Logger - handlers map[string]Handler - clock func() time.Time - observer Observer - ready atomic.Bool - tracer trace.Tracer - propagator propagation.TextMapPropagator - middleware []Middleware -} - -func NewRunner(store repository.JobStore, owner string, cfg config.Config, logger zerolog.Logger, opts ...RunnerOption) (*Runner, error) { - if store == nil || owner == "" { - return nil, errors.New("job runner store and owner are required") - } - provider := noop.NewTracerProvider() - runner := &Runner{store: store, owner: owner, cfg: cfg.Jobs, logger: logger, handlers: make(map[string]Handler), clock: time.Now, tracer: provider.Tracer("github.com/mooncode-ai/mooncode/internal/jobs"), propagator: propagation.TraceContext{}} - for _, option := range opts { - if option != nil { - if err := option(runner); err != nil { - return nil, err - } - } - } - if len(runner.handlers) == 0 { - return nil, errors.New("job runner requires at least one handler") - } - return runner, nil -} - -func (r *Runner) Ready() bool { return r.ready.Load() } - -func (r *Runner) Run(ctx context.Context) error { - r.ready.Store(true) - defer r.ready.Store(false) - types := make([]string, 0, len(r.handlers)) - for jobType := range r.handlers { - types = append(types, jobType) - } - ticker := time.NewTicker(r.cfg.PollInterval) - defer ticker.Stop() - for { - if err := r.refreshQueueDepth(ctx, types); err != nil && !errors.Is(err, context.Canceled) { - r.logger.Warn().Err(err).Str("operation", "job.queue_depth").Msg("unable to collect background job queue depth") - } - job, err := r.store.ClaimJob(ctx, types, r.owner, r.clock().Add(r.cfg.LeaseDuration)) - if err == nil { - if runErr := r.runJob(ctx, job, r.handlers[job.Type]); runErr != nil && !errors.Is(runErr, context.Canceled) { - r.logger.Error().Err(runErr).Str("operation", "job.run").Str("job_type", job.Type).Msg("job runner persistence failure") - } - continue - } - if !errors.Is(err, repository.ErrNotFound) && !errors.Is(err, context.Canceled) { - r.logger.Warn().Err(err).Str("operation", "job.claim").Msg("unable to claim background job") - } - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - } - } -} - -func (r *Runner) refreshQueueDepth(ctx context.Context, types []string) error { - if r.observer == nil { - return nil - } - depths, err := r.store.ListJobQueueDepths(ctx, types) - if err != nil { - return err - } - for _, jobType := range types { - r.observer.SetJobQueueDepth(jobType, "queued", 0) - r.observer.SetJobQueueDepth(jobType, "running", 0) - } - for _, depth := range depths { - r.observer.SetJobQueueDepth(depth.Type, depth.Status, float64(depth.Depth)) - } - return nil -} - -func (r *Runner) runJob(parent context.Context, job model.Job, handler Handler) error { - startedAt := r.clock() - ctx, span := r.tracer.Start(parent, "job "+job.Type, trace.WithSpanKind(trace.SpanKindConsumer), trace.WithLinks(r.jobLinks(job)...), trace.WithAttributes(attribute.String("job.type", boundedJobType(job.Type)), attribute.Int("job.attempt", int(job.Attempt)))) - defer span.End() - ctx, cancel := context.WithCancel(ctx) - defer cancel() - done := make(chan error, 1) - go func() { - defer func() { - if recovered := recover(); recovered != nil { - done <- Retryable("job.handler_panic", fmt.Errorf("handler panic (%T)\n%s", recovered, debug.Stack())) - } - }() - handle := Chain(handler.Handle, r.middleware...) - done <- handle(ctx, job) - }() - ticker := time.NewTicker(r.cfg.RenewInterval) - defer ticker.Stop() - var handleErr error - for { - select { - case handleErr = <-done: - finishErr := r.finish(job, handleErr, r.clock().Sub(startedAt).Seconds()) - if handleErr != nil { - span.RecordError(handleErr) - span.SetStatus(codes.Error, "job handler failed") - } - if finishErr != nil { - span.RecordError(finishErr) - span.SetStatus(codes.Error, "job persistence failed") - } - return finishErr - case <-ticker.C: - if err := r.store.RenewJobLease(parent, job, r.clock().Add(r.cfg.LeaseDuration)); err != nil { - cancel() - <-done - r.observe(job.Type, "lease_lost", r.clock().Sub(startedAt).Seconds()) - span.RecordError(err) - span.SetStatus(codes.Error, "job lease lost") - return err - } - case <-parent.Done(): - cancel() - <-done - return nil - } - } -} - -func (r *Runner) jobLinks(job model.Job) []trace.Link { - carrier := propagation.MapCarrier{} - if len(job.TraceContext) == 0 || json.Unmarshal(job.TraceContext, &carrier) != nil { - return nil - } - ctx := r.propagator.Extract(context.Background(), carrier) - spanContext := trace.SpanContextFromContext(ctx) - if !spanContext.IsValid() { - return nil - } - return []trace.Link{{SpanContext: spanContext}} -} - -func boundedJobType(value string) string { - if value == "repository.sync" { - return value - } - return "other" -} - -func (r *Runner) finish(job model.Job, handleErr error, seconds float64) error { - if handleErr == nil { - err := r.store.CompleteJob(context.Background(), job) - if err == nil { - r.observe(job.Type, "success", seconds) - } - return err - } - classified := &Error{Code: "job.failed", Retryable: true, Err: handleErr} - var provided *Error - if errors.As(handleErr, &provided) { - classified = provided - } - message := safeErrorMessage(classified.Err) - if classified.Retryable && job.Attempt < job.MaxAttempts { - delay := r.cfg.RetryBaseDelay * time.Duration(1< maxBytes { - c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"error": gin.H{"code": "request.too_large", "message": "Request body is too large", "requestId": RequestIDFromContext(c.Request.Context())}}) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) - } - c.Next() - } -} diff --git a/internal/middleware/body_test.go b/internal/middleware/body_test.go deleted file mode 100644 index 79edefe..0000000 --- a/internal/middleware/body_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package middleware - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestJSONBodyGuard(t *testing.T) { - gin.SetMode(gin.TestMode) - engine := gin.New() - engine.Use(JSONBodyGuard(8)) - engine.POST("/", func(c *gin.Context) { c.Status(http.StatusNoContent) }) - - for _, test := range []struct { - name, contentType, body string - status int - }{ - {name: "JSON", contentType: "application/json; charset=utf-8", body: `{}`, status: http.StatusNoContent}, - {name: "empty", contentType: "", body: "", status: http.StatusNoContent}, - {name: "form", contentType: "application/x-www-form-urlencoded", body: "x=1", status: http.StatusUnsupportedMediaType}, - {name: "too large", contentType: "application/json", body: "123456789", status: http.StatusRequestEntityTooLarge}, - } { - t.Run(test.name, func(t *testing.T) { - request := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(test.body)) - request.Header.Set("Content-Type", test.contentType) - response := httptest.NewRecorder() - engine.ServeHTTP(response, request) - require.Equal(t, test.status, response.Code) - }) - } -} diff --git a/internal/middleware/identity.go b/internal/middleware/identity.go deleted file mode 100644 index 9cf9395..0000000 --- a/internal/middleware/identity.go +++ /dev/null @@ -1,72 +0,0 @@ -package middleware - -import ( - "crypto/subtle" - "errors" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/mooncode-ai/mooncode/internal/config" - identityctx "github.com/mooncode-ai/mooncode/internal/identity" - "github.com/mooncode-ai/mooncode/internal/service" - "github.com/rs/zerolog" -) - -func GatewayIdentity(cfg config.IdentityConfig, identities *service.IdentityService, logger zerolog.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - provided := c.GetHeader(identityctx.GatewayTokenHeader) - if subtle.ConstantTimeCompare([]byte(provided), []byte(cfg.GatewayToken)) != 1 { - abortIdentity(c, http.StatusUnauthorized, "identity.untrusted_gateway", "Authentication required") - return - } - external, err := identityctx.ParseHeaders(c.Request.Header, cfg) - if err != nil { - if errors.Is(err, identityctx.ErrSubjectRequired) { - abortIdentity(c, http.StatusUnauthorized, "identity.subject_required", "Authentication required") - return - } - abortIdentity(c, http.StatusUnauthorized, "identity.invalid_headers", "Authentication required") - return - } - principal, err := identities.Resolve(c.Request.Context(), external) - if err != nil { - switch { - case errors.Is(err, service.ErrSubjectRequired): - abortIdentity(c, http.StatusUnauthorized, "identity.subject_required", "Authentication required") - case errors.Is(err, service.ErrRegistrationClosed): - abortIdentity(c, http.StatusForbidden, "registration.closed", "Registration is closed") - case errors.Is(err, service.ErrAccountSuspended): - abortIdentity(c, http.StatusForbidden, "account.suspended", "Account is suspended") - case errors.Is(err, service.ErrAccountDeleted): - abortIdentity(c, http.StatusForbidden, "account.unavailable", "Account is unavailable") - default: - logger.Error().Err(err).Str("operation", "identity.resolve").Msg("failed to resolve authenticated identity") - abortIdentity(c, http.StatusInternalServerError, "identity.resolve_failed", "Unable to initialize user") - } - return - } - if principal.User.Status == "pending" && !pendingRouteAllowed(c.Request.URL.Path) { - abortIdentity(c, http.StatusForbidden, "account.onboarding_required", "Complete account registration") - return - } - c.Request = c.Request.WithContext(identityctx.WithPrincipal(c.Request.Context(), principal)) - c.Next() - } -} - -func pendingRouteAllowed(path string) bool { - path = strings.TrimSuffix(path, "/") - switch path { - case "/api/v1/session", "/api/v1/csrf", "/api/v1/onboarding", "/api/v1/onboarding/complete", "/api/v1/invitations/accept": - return true - default: - return false - } -} - -func abortIdentity(c *gin.Context, status int, code, message string) { - c.AbortWithStatusJSON(status, gin.H{"error": gin.H{ - "code": code, "message": message, "requestId": RequestIDFromContext(c.Request.Context()), - }}) -} diff --git a/internal/middleware/identity_test.go b/internal/middleware/identity_test.go deleted file mode 100644 index 7b61d36..0000000 --- a/internal/middleware/identity_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package middleware - -import ( - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/repository/memory" - "github.com/mooncode-ai/mooncode/internal/service" - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" -) - -func TestGatewayIdentityRequiresOAuthSubjectAndRestrictsPendingRoutes(t *testing.T) { - gin.SetMode(gin.TestMode) - cfg, err := config.NewLoader().Load("", map[string]any{"registration.mode": "public"}) - require.NoError(t, err) - identities := service.NewIdentityService(memory.New(), cfg) - engine := gin.New() - engine.Use(GatewayIdentity(cfg.Identity, identities, zerolog.New(io.Discard))) - engine.GET("/api/v1/session", func(c *gin.Context) { c.Status(http.StatusNoContent) }) - engine.GET("/api/v1/workspaces", func(c *gin.Context) { c.Status(http.StatusNoContent) }) - - missingSubject := identityRequest(cfg, "/api/v1/session", false) - missingResponse := httptest.NewRecorder() - engine.ServeHTTP(missingResponse, missingSubject) - require.Equal(t, http.StatusUnauthorized, missingResponse.Code) - require.Contains(t, missingResponse.Body.String(), "identity.subject_required") - - allowedResponse := httptest.NewRecorder() - engine.ServeHTTP(allowedResponse, identityRequest(cfg, "/api/v1/session", true)) - require.Equal(t, http.StatusNoContent, allowedResponse.Code) - - blockedResponse := httptest.NewRecorder() - engine.ServeHTTP(blockedResponse, identityRequest(cfg, "/api/v1/workspaces", true)) - require.Equal(t, http.StatusForbidden, blockedResponse.Code) - require.Contains(t, blockedResponse.Body.String(), "account.onboarding_required") -} - -func TestGatewayIdentityDoesNotPersistUnknownIdentityWhenRegistrationDisabled(t *testing.T) { - gin.SetMode(gin.TestMode) - cfg, err := config.NewLoader().Load("", map[string]any{"registration.mode": "disabled"}) - require.NoError(t, err) - identities := service.NewIdentityService(memory.New(), cfg) - engine := gin.New() - engine.Use(GatewayIdentity(cfg.Identity, identities, zerolog.New(io.Discard))) - engine.GET("/api/v1/session", func(c *gin.Context) { c.Status(http.StatusNoContent) }) - - response := httptest.NewRecorder() - engine.ServeHTTP(response, identityRequest(cfg, "/api/v1/session", true)) - require.Equal(t, http.StatusForbidden, response.Code) - require.Contains(t, response.Body.String(), "registration.closed") -} - -func identityRequest(cfg config.Config, path string, includeSubject bool) *http.Request { - request := httptest.NewRequest(http.MethodGet, path, nil) - request.Header.Set("X-MoonCode-Gateway-Token", cfg.Identity.GatewayToken) - request.Header.Set("Remote-User", "moon") - request.Header.Set("Remote-Email", "moon@example.com") - if includeSubject { - request.Header.Set("Remote-Sub", "42") - } - return request -} diff --git a/internal/middleware/rate_limit.go b/internal/middleware/rate_limit.go deleted file mode 100644 index 6747431..0000000 --- a/internal/middleware/rate_limit.go +++ /dev/null @@ -1,114 +0,0 @@ -package middleware - -import ( - "net" - "net/http" - "sync" - "time" - - "github.com/gin-gonic/gin" - "github.com/mooncode-ai/mooncode/internal/config" - identityctx "github.com/mooncode-ai/mooncode/internal/identity" -) - -type bucket struct { - tokens float64 - updatedAt time.Time - seenAt time.Time -} -type RateLimitObserver interface{ ObserveRateLimited(scope, operation string) } -type RateLimiter struct { - mu sync.Mutex - cfg config.RateLimitConfig - entries map[string]*bucket - clock func() time.Time - metrics RateLimitObserver -} -type RateLimiterOption func(*RateLimiter) - -func WithRateLimitClock(clock func() time.Time) RateLimiterOption { - return func(limiter *RateLimiter) { - if clock != nil { - limiter.clock = clock - } - } -} -func NewRateLimiter(cfg config.RateLimitConfig, metrics RateLimitObserver, opts ...RateLimiterOption) *RateLimiter { - limiter := &RateLimiter{cfg: cfg, entries: make(map[string]*bucket), clock: time.Now, metrics: metrics} - for _, option := range opts { - if option != nil { - option(limiter) - } - } - return limiter -} -func (l *RateLimiter) Middleware() gin.HandlerFunc { - return func(c *gin.Context) { - principal, _ := identityctx.PrincipalFromContext(c.Request.Context()) - host, _, _ := net.SplitHostPort(c.Request.RemoteAddr) - checks := []struct { - scope, key string - limit config.LimitConfig - }{{"ip", host, l.cfg.PerIP}, {"user", principal.User.ID.String(), l.cfg.PerUser}} - if workspaceID := c.Param("workspaceID"); workspaceID != "" { - checks = append(checks, struct { - scope, key string - limit config.LimitConfig - }{"workspace", workspaceID, l.cfg.PerWorkspace}) - } - for _, check := range checks { - if check.key != "" && !l.allow(check.scope+":"+check.key, check.limit) { - operation := "read" - if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead && c.Request.Method != http.MethodOptions { - operation = "write" - } - if l.metrics != nil { - l.metrics.ObserveRateLimited(check.scope, operation) - } - c.Header("Retry-After", "1") - c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": gin.H{"code": "rate_limit.exceeded", "message": "Too many requests", "requestId": RequestIDFromContext(c.Request.Context())}}) - return - } - } - c.Next() - } -} -func (l *RateLimiter) allow(key string, limit config.LimitConfig) bool { - l.mu.Lock() - defer l.mu.Unlock() - now := l.clock() - entry := l.entries[key] - if entry == nil { - if len(l.entries) >= l.cfg.MaxEntries { - l.evict(now) - } - entry = &bucket{tokens: limit.Burst, updatedAt: now} - l.entries[key] = entry - } - elapsed := now.Sub(entry.updatedAt).Seconds() - entry.tokens = min(limit.Burst, entry.tokens+elapsed*limit.Rate) - entry.updatedAt, entry.seenAt = now, now - if entry.tokens < 1 { - return false - } - entry.tokens-- - return true -} -func (l *RateLimiter) evict(now time.Time) { - for key, entry := range l.entries { - if now.Sub(entry.seenAt) >= l.cfg.EntryTTL { - delete(l.entries, key) - } - } - if len(l.entries) < l.cfg.MaxEntries { - return - } - var oldestKey string - var oldest time.Time - for key, entry := range l.entries { - if oldestKey == "" || entry.seenAt.Before(oldest) { - oldestKey, oldest = key, entry.seenAt - } - } - delete(l.entries, oldestKey) -} diff --git a/internal/middleware/rate_limit_test.go b/internal/middleware/rate_limit_test.go deleted file mode 100644 index 64716d7..0000000 --- a/internal/middleware/rate_limit_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package middleware - -import ( - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/config" - identityctx "github.com/mooncode-ai/mooncode/internal/identity" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/stretchr/testify/require" -) - -func TestRateLimiterRefillsTokens(t *testing.T) { - now := time.Unix(100, 0) - limiter := NewRateLimiter(testRateLimitConfig(), nil, WithRateLimitClock(func() time.Time { return now })) - limit := config.LimitConfig{Rate: 1, Burst: 1} - require.True(t, limiter.allow("ip:one", limit)) - require.False(t, limiter.allow("ip:one", limit)) - now = now.Add(time.Second) - require.True(t, limiter.allow("ip:one", limit)) -} - -func TestRateLimiterChecksIPUserAndWorkspace(t *testing.T) { - gin.SetMode(gin.TestMode) - cfg := testRateLimitConfig() - cfg.PerIP.Burst, cfg.PerUser.Burst, cfg.PerWorkspace.Burst = 2, 2, 1 - limiter := NewRateLimiter(cfg, nil) - userID := uuid.MustParse("018f0000-0000-7000-8000-000000000001") - engine := gin.New() - engine.Use(func(c *gin.Context) { - principal := model.Principal{User: model.User{ID: userID}} - c.Request = c.Request.WithContext(identityctx.WithPrincipal(c.Request.Context(), principal)) - c.Next() - }, limiter.Middleware()) - engine.GET("/workspaces/:workspaceID/resources", func(c *gin.Context) { c.Status(http.StatusNoContent) }) - - workspaceID := "018f0000-0000-7000-8000-000000000002" - for index, expected := range []int{http.StatusNoContent, http.StatusTooManyRequests} { - request := httptest.NewRequest(http.MethodGet, "/workspaces/"+workspaceID+"/resources", nil) - request.RemoteAddr = "192.0.2.5:1234" - response := httptest.NewRecorder() - engine.ServeHTTP(response, request) - require.Equal(t, expected, response.Code, "request %d", index+1) - if expected == http.StatusTooManyRequests { - require.Equal(t, "1", response.Header().Get("Retry-After")) - require.JSONEq(t, `{"error":{"code":"rate_limit.exceeded","message":"Too many requests","requestId":""}}`, response.Body.String()) - } - } - require.Contains(t, limiter.entries, "ip:192.0.2.5") - require.Contains(t, limiter.entries, "user:"+userID.String()) - require.Contains(t, limiter.entries, "workspace:"+workspaceID) -} - -func TestRateLimiterEvictsExpiredAndOldestEntries(t *testing.T) { - now := time.Unix(100, 0) - cfg := testRateLimitConfig() - cfg.EntryTTL, cfg.MaxEntries = time.Minute, 2 - limiter := NewRateLimiter(cfg, nil, WithRateLimitClock(func() time.Time { return now })) - limit := config.LimitConfig{Rate: 1, Burst: 1} - require.True(t, limiter.allow("one", limit)) - now = now.Add(time.Second) - require.True(t, limiter.allow("two", limit)) - now = now.Add(time.Second) - require.True(t, limiter.allow("three", limit)) - require.NotContains(t, limiter.entries, "one") - require.Len(t, limiter.entries, 2) - - now = now.Add(2 * time.Minute) - require.True(t, limiter.allow("four", limit)) - require.NotContains(t, limiter.entries, "two") - require.NotContains(t, limiter.entries, "three") -} - -func testRateLimitConfig() config.RateLimitConfig { - return config.RateLimitConfig{ - PerIP: config.LimitConfig{Rate: 100, Burst: 100}, PerUser: config.LimitConfig{Rate: 100, Burst: 100}, - PerWorkspace: config.LimitConfig{Rate: 100, Burst: 100}, EntryTTL: time.Minute, MaxEntries: 100, - } -} diff --git a/internal/middleware/request_id.go b/internal/middleware/request_id.go deleted file mode 100644 index d002e98..0000000 --- a/internal/middleware/request_id.go +++ /dev/null @@ -1,77 +0,0 @@ -package middleware - -import ( - "context" - "crypto/rand" - "encoding/hex" - "fmt" - "net/http" - "sync/atomic" - "time" - - "github.com/gin-gonic/gin" -) - -const RequestIDHeader = "X-Request-ID" - -type requestIDKey struct{} - -var fallbackRequestID atomic.Uint64 - -func RequestID() gin.HandlerFunc { - return func(c *gin.Context) { - requestID := RequestIDFromContext(c.Request.Context()) - if requestID == "" { - requestID = c.GetHeader(RequestIDHeader) - } - if !validRequestID(requestID) { - requestID = newRequestID() - } - ctx := context.WithValue(c.Request.Context(), requestIDKey{}, requestID) - c.Request = c.Request.WithContext(ctx) - c.Header(RequestIDHeader, requestID) - c.Next() - } -} - -func RequestIDHTTP(next http.Handler) http.Handler { - return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requestID := request.Header.Get(RequestIDHeader) - if !validRequestID(requestID) { - requestID = newRequestID() - } - writer.Header().Set(RequestIDHeader, requestID) - ctx := context.WithValue(request.Context(), requestIDKey{}, requestID) - next.ServeHTTP(writer, request.WithContext(ctx)) - }) -} - -func RequestIDFromContext(ctx context.Context) string { - requestID, _ := ctx.Value(requestIDKey{}).(string) - return requestID -} - -func validRequestID(value string) bool { - if len(value) < 8 || len(value) > 128 { - return false - } - for index := 0; index < len(value); index++ { - character := value[index] - if (character >= 'a' && character <= 'z') || - (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9') || - character == '-' || character == '_' || character == '.' { - continue - } - return false - } - return true -} - -func newRequestID() string { - var value [16]byte - if _, err := rand.Read(value[:]); err != nil { - return fmt.Sprintf("fallback-%x-%x", time.Now().UnixNano(), fallbackRequestID.Add(1)) - } - return hex.EncodeToString(value[:]) -} diff --git a/internal/middleware/request_id_test.go b/internal/middleware/request_id_test.go deleted file mode 100644 index bfa4b6b..0000000 --- a/internal/middleware/request_id_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package middleware - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestRequestIDPreservesValidValue(t *testing.T) { - gin.SetMode(gin.TestMode) - engine := gin.New() - engine.Use(RequestID()) - engine.GET("/", func(c *gin.Context) { - c.String(http.StatusOK, RequestIDFromContext(c.Request.Context())) - }) - - request := httptest.NewRequest(http.MethodGet, "/", nil) - request.Header.Set(RequestIDHeader, "request-123") - response := httptest.NewRecorder() - engine.ServeHTTP(response, request) - - require.Equal(t, http.StatusOK, response.Code) - require.Equal(t, "request-123", response.Body.String()) - require.Equal(t, "request-123", response.Header().Get(RequestIDHeader)) -} - -func TestRequestIDReplacesUnsafeValue(t *testing.T) { - require.False(t, validRequestID("unsafe request id")) - require.Len(t, newRequestID(), 32) -} diff --git a/internal/model/channel.go b/internal/model/channel.go deleted file mode 100644 index 690d4ea..0000000 --- a/internal/model/channel.go +++ /dev/null @@ -1,72 +0,0 @@ -package model - -import ( - "encoding/json" - "time" - - "github.com/google/uuid" - channelcore "github.com/mooncode-ai/mooncode/pkg/channel" -) - -type ChannelConfig struct { - Values map[string]any `json:"values"` - SenderAllowList []string `json:"senderAllowList"` - GroupPolicy channelcore.GroupPolicy `json:"groupPolicy"` -} - -type ChannelInstance struct { - ID uuid.UUID `json:"id"` - WorkspaceID uuid.UUID `json:"workspaceId"` - Type string `json:"type"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - Config ChannelConfig `json:"config"` - SecretRef *uuid.UUID `json:"-"` - SecretConfigured bool `json:"secretConfigured"` - ConfigVersion int64 `json:"configVersion"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type ChannelLease struct { - ChannelInstanceID uuid.UUID - Owner string - LeaseUntil time.Time - FencingToken int64 -} -type ChannelRuntimeStatus struct { - ChannelInstanceID uuid.UUID `json:"channelInstanceId"` - State string `json:"state"` - BackendInstanceID string `json:"backendInstanceId,omitempty"` - FencingToken int64 `json:"-"` - LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"` - LastErrorCode string `json:"lastErrorCode,omitempty"` - LastErrorMessage string `json:"lastErrorMessage,omitempty"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type IMMessage struct { - ID uuid.UUID `json:"id"` - ChannelInstanceID uuid.UUID `json:"channelInstanceId"` - ChannelName string `json:"channelName"` - ChannelType string `json:"channelType"` - ConversationID uuid.UUID `json:"conversationId"` - ConversationExternalID string `json:"conversationExternalId"` - ConversationType string `json:"conversationType"` - SenderCanonicalID string `json:"senderCanonicalId"` - SenderDisplayName string `json:"senderDisplayName"` - ExternalMessageID string `json:"externalMessageId"` - Content json.RawMessage `json:"content"` - OccurredAt time.Time `json:"occurredAt"` - ReceivedAt time.Time `json:"receivedAt"` -} -type IMConversation struct { - ID uuid.UUID `json:"id"` - ChannelInstanceID uuid.UUID `json:"channelInstanceId"` - ChannelName string `json:"channelName"` - ChannelType string `json:"channelType"` - ExternalID string `json:"externalId"` - Type string `json:"type"` - Title string `json:"title"` - UpdatedAt time.Time `json:"updatedAt"` -} diff --git a/internal/model/identity.go b/internal/model/identity.go deleted file mode 100644 index a2d74e5..0000000 --- a/internal/model/identity.go +++ /dev/null @@ -1,104 +0,0 @@ -package model - -import ( - "time" - - "github.com/google/uuid" -) - -type ExternalIdentity struct { - Issuer string - Subject string - Username string - Email string - DisplayName string -} - -type User struct { - ID uuid.UUID `json:"id"` - Issuer string `json:"issuer"` - ExternalSubject string `json:"externalSubject"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"displayName"` - Status string `json:"status"` - TermsVersion string `json:"termsVersion"` - PrivacyVersion string `json:"privacyVersion"` - ActivatedAt *time.Time `json:"activatedAt,omitempty"` - SuspendedAt *time.Time `json:"suspendedAt,omitempty"` - LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` - AgreementsAcceptedAt *time.Time `json:"agreementsAcceptedAt,omitempty"` - DeletedAt *time.Time `json:"deletedAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Workspace struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Kind string `json:"kind"` - CreatedBy uuid.UUID `json:"createdBy"` - Role string `json:"role"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Principal struct { - User User `json:"user"` -} - -type Session struct { - User User `json:"user"` - Workspaces []Workspace `json:"workspaces"` - Auth SessionAuth `json:"auth"` - Registration SessionRegistration `json:"registration"` -} - -type SessionAuth struct { - LogoutURL string `json:"logoutUrl"` -} - -type SessionRegistration struct { - Mode string `json:"mode"` - TermsVersion string `json:"termsVersion"` - PrivacyVersion string `json:"privacyVersion"` -} - -type WorkspaceInvitation struct { - ID uuid.UUID `json:"id"` - WorkspaceID uuid.UUID `json:"workspaceId"` - Email string `json:"email"` - Role string `json:"role"` - InvitedBy uuid.UUID `json:"invitedBy"` - ExpiresAt time.Time `json:"expiresAt"` - AcceptedAt *time.Time `json:"acceptedAt,omitempty"` - AcceptedBy *uuid.UUID `json:"acceptedBy,omitempty"` - RevokedAt *time.Time `json:"revokedAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Token string `json:"token,omitempty"` -} - -type AccountAuditLog struct { - ID uuid.UUID `json:"id"` - UserID uuid.UUID `json:"userId"` - ActorUserID *uuid.UUID `json:"actorUserId,omitempty"` - Action string `json:"action"` - Result string `json:"result"` - Provider string `json:"provider"` - RequestID string `json:"requestId"` - Metadata map[string]any `json:"metadata"` - OccurredAt time.Time `json:"occurredAt"` -} - -type WorkspaceMember struct { - WorkspaceID uuid.UUID `json:"workspaceId"` - UserID uuid.UUID `json:"userId"` - Role string `json:"role"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"displayName"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} diff --git a/internal/model/repository.go b/internal/model/repository.go deleted file mode 100644 index d752b41..0000000 --- a/internal/model/repository.go +++ /dev/null @@ -1,102 +0,0 @@ -package model - -import ( - "encoding/json" - "time" - - "github.com/google/uuid" -) - -type SCMConnection struct { - ID uuid.UUID `json:"id"` - WorkspaceID uuid.UUID `json:"workspaceId"` - Type string `json:"type"` - Name string `json:"name"` - BaseURL string `json:"baseUrl"` - AuthType string `json:"authType"` - SecretRef *uuid.UUID `json:"-"` - SecretConfigured bool `json:"secretConfigured"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Repository struct { - ID uuid.UUID `json:"id"` - WorkspaceID uuid.UUID `json:"workspaceId"` - ConnectionID *uuid.UUID `json:"connectionId,omitempty"` - Name string `json:"name"` - CloneURL string `json:"cloneUrl"` - NormalizedURL string `json:"normalizedUrl"` - Ref string `json:"ref"` - CurrentCommitSHA string `json:"currentCommitSha,omitempty"` - State string `json:"state"` - LastErrorCode string `json:"lastErrorCode,omitempty"` - LastErrorMessage string `json:"lastErrorMessage,omitempty"` - SyncedAt *time.Time `json:"syncedAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Job struct { - ID uuid.UUID `json:"id"` - WorkspaceID uuid.UUID `json:"workspaceId"` - Type string `json:"type"` - Payload json.RawMessage `json:"-"` - Status string `json:"status"` - Attempt int32 `json:"attempt"` - MaxAttempts int32 `json:"maxAttempts"` - LeaseOwner string `json:"-"` - LeaseUntil *time.Time `json:"-"` - FencingToken int64 `json:"-"` - RunAfter time.Time `json:"runAfter"` - TraceContext json.RawMessage `json:"-"` - LastErrorCode string `json:"lastErrorCode,omitempty"` - LastErrorMessage string `json:"lastErrorMessage,omitempty"` - CreatedAt time.Time `json:"createdAt"` - StartedAt *time.Time `json:"startedAt,omitempty"` - FinishedAt *time.Time `json:"finishedAt,omitempty"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type JobQueueDepth struct { - Type string - Status string - Depth int64 -} - -type WorkspaceOverview struct { - RepositoryCount int64 `json:"repositoryCount"` - ActiveChannelCount int64 `json:"activeChannelCount"` - FailedJobCount int64 `json:"failedJobCount"` - RecentMessages []IMMessage `json:"recentMessages"` -} - -type RepositorySyncPayload struct { - RepositoryID uuid.UUID `json:"repositoryId"` -} - -type OutboxEvent struct { - ID uuid.UUID - WorkspaceID uuid.UUID - Aggregate string - AggregateID uuid.UUID - Type string - Payload json.RawMessage - TraceContext json.RawMessage - LeaseOwner string - LeaseUntil *time.Time - Attempt int32 - CreatedAt time.Time -} - -type AuditLog struct { - ID uuid.UUID `json:"id"` - WorkspaceID uuid.UUID `json:"workspaceId"` - ActorUserID *uuid.UUID `json:"actorUserId,omitempty"` - Action string `json:"action"` - ResourceType string `json:"resourceType"` - ResourceID *uuid.UUID `json:"resourceId,omitempty"` - Result string `json:"result"` - Metadata json.RawMessage `json:"metadata"` - OccurredAt time.Time `json:"occurredAt"` -} diff --git a/internal/observability/http.go b/internal/observability/http.go deleted file mode 100644 index 7ddb0d4..0000000 --- a/internal/observability/http.go +++ /dev/null @@ -1,202 +0,0 @@ -package observability - -import ( - "context" - "errors" - "fmt" - "net/http" - "runtime/debug" - "strconv" - "time" - - "github.com/gin-gonic/gin" - "github.com/mooncode-ai/mooncode/internal/middleware" - "github.com/rs/zerolog" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" -) - -func TraceMiddleware(provider trace.TracerProvider, propagator propagation.TextMapPropagator) gin.HandlerFunc { - tracer := provider.Tracer("github.com/mooncode-ai/mooncode/internal/observability/http") - return func(c *gin.Context) { - ctx := propagator.Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header)) - ctx, span := tracer.Start(ctx, "HTTP "+normalizeMethod(c.Request.Method), - trace.WithSpanKind(trace.SpanKindServer), - trace.WithAttributes(attribute.String("http.request.method", normalizeMethod(c.Request.Method))), - ) - defer span.End() - - c.Request = c.Request.WithContext(ctx) - c.Next() - - route := routeTemplate(c) - span.SetName(normalizeMethod(c.Request.Method) + " " + route) - span.SetAttributes( - attribute.String("http.route", route), - attribute.Int("http.response.status_code", c.Writer.Status()), - ) - if c.Writer.Status() >= http.StatusInternalServerError { - span.RecordError(errors.New("HTTP request failed")) - span.SetStatus(codes.Error, http.StatusText(c.Writer.Status())) - } - } -} - -func AccessLogMiddleware(base zerolog.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - startedAt := time.Now() - c.Next() - - logger := contextualLogger(c.Request.Context(), base) - logger.Info(). - Str("operation", "http.request"). - Str("http_method", normalizeMethod(c.Request.Method)). - Str("http_route", routeTemplate(c)). - Int("http_status", c.Writer.Status()). - Int("response_bytes", c.Writer.Size()). - Dur("duration", time.Since(startedAt)). - Msg("HTTP request completed") - } -} - -func MetricsMiddleware(metrics *Metrics) gin.HandlerFunc { - return func(c *gin.Context) { - startedAt := time.Now() - c.Next() - metrics.Observe( - normalizeMethod(c.Request.Method), - routeTemplate(c), - statusClass(c.Writer.Status()), - time.Since(startedAt).Seconds(), - ) - } -} - -func RecoveryMiddleware(base zerolog.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - defer func() { - if recovered := recover(); recovered != nil { - logger := contextualLogger(c.Request.Context(), base) - logger.Error(). - Str("operation", "http.request"). - Str("error_code", "http.panic"). - Str("panic_type", fmt.Sprintf("%T", recovered)). - Str("stack", string(debug.Stack())). - Msg("recovered HTTP handler panic") - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ - "error": gin.H{ - "code": "internal.error", - "message": "Internal server error", - "requestId": middleware.RequestIDFromContext(c.Request.Context()), - }, - }) - } - }() - c.Next() - } -} - -func AccessLogHTTP(base zerolog.Logger, next http.Handler) http.Handler { - return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - startedAt := time.Now() - response := &statusRecorder{ResponseWriter: writer, status: http.StatusOK} - next.ServeHTTP(response, request) - logger := contextualLogger(request.Context(), base) - logger.Info(). - Str("operation", "admin.request"). - Str("http_method", normalizeMethod(request.Method)). - Str("http_route", adminRoute(request.URL.Path)). - Int("http_status", response.status). - Dur("duration", time.Since(startedAt)). - Msg("admin HTTP request completed") - }) -} - -func RecoveryHTTP(base zerolog.Logger, next http.Handler) http.Handler { - return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - defer func() { - if recovered := recover(); recovered != nil { - logger := contextualLogger(request.Context(), base) - logger.Error(). - Str("operation", "admin.request"). - Str("error_code", "admin.panic"). - Str("panic_type", fmt.Sprintf("%T", recovered)). - Str("stack", string(debug.Stack())). - Msg("recovered admin handler panic") - http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - }() - next.ServeHTTP(writer, request) - }) -} - -func contextualLogger(ctx context.Context, base zerolog.Logger) zerolog.Logger { - logContext := base.With() - if requestID := middleware.RequestIDFromContext(ctx); requestID != "" { - logContext = logContext.Str("request_id", requestID) - } - spanContext := trace.SpanContextFromContext(ctx) - if spanContext.IsValid() { - logContext = logContext. - Str("trace_id", spanContext.TraceID().String()). - Str("span_id", spanContext.SpanID().String()) - } - return logContext.Logger() -} - -func routeTemplate(c *gin.Context) string { - if route := c.FullPath(); route != "" { - return route - } - return "unknown" -} - -func adminRoute(path string) string { - switch path { - case "/livez", "/readyz", "/metrics": - return path - default: - return "unknown" - } -} - -func normalizeMethod(method string) string { - switch method { - case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, - http.MethodPatch, http.MethodDelete, http.MethodConnect, http.MethodOptions, http.MethodTrace: - return method - default: - return "OTHER" - } -} - -func statusClass(status int) string { - if status < 100 || status > 599 { - return "unknown" - } - return strconv.Itoa(status/100) + "xx" -} - -type statusRecorder struct { - http.ResponseWriter - status int - wroteHeader bool -} - -func (r *statusRecorder) WriteHeader(status int) { - if r.wroteHeader { - return - } - r.wroteHeader = true - r.status = status - r.ResponseWriter.WriteHeader(status) -} - -func (r *statusRecorder) Write(body []byte) (int, error) { - if !r.wroteHeader { - r.WriteHeader(http.StatusOK) - } - return r.ResponseWriter.Write(body) -} diff --git a/internal/observability/http_test.go b/internal/observability/http_test.go deleted file mode 100644 index a0c6df5..0000000 --- a/internal/observability/http_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package observability - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/mooncode-ai/mooncode/internal/middleware" - "github.com/prometheus/client_golang/prometheus" - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel/propagation" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - "go.opentelemetry.io/otel/sdk/trace/tracetest" -) - -func TestHTTPBoundaryObservesRecoveredPanic(t *testing.T) { - gin.SetMode(gin.TestMode) - var logs bytes.Buffer - logger := zerolog.New(&logs) - registry := prometheus.NewRegistry() - metrics := NewMetrics(registry) - recorder := tracetest.NewSpanRecorder() - provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) - defer func() { require.NoError(t, provider.Shutdown(t.Context())) }() - - engine := gin.New() - engine.Use( - middleware.RequestID(), - TraceMiddleware(provider, propagation.TraceContext{}), - AccessLogMiddleware(logger), - MetricsMiddleware(metrics), - RecoveryMiddleware(logger), - ) - engine.GET("/panic/:resourceID", func(*gin.Context) { panic("boom") }) - response := httptest.NewRecorder() - engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/panic/private-resource-id", nil)) - - require.Equal(t, http.StatusInternalServerError, response.Code) - require.Contains(t, response.Body.String(), `"code":"internal.error"`) - require.Contains(t, logs.String(), `"error_code":"http.panic"`) - require.Contains(t, logs.String(), `"http_status":500`) - spans := recorder.Ended() - require.Len(t, spans, 1) - require.Equal(t, "GET /panic/:resourceID", spans[0].Name()) - require.True(t, spans[0].Status().Code.String() == "Error") - - families, err := registry.Gather() - require.NoError(t, err) - for _, family := range families { - if family.GetName() == "mooncode_http_requests_total" { - require.Len(t, family.Metric, 1) - for _, label := range family.Metric[0].Label { - require.NotEqual(t, "private-resource-id", label.GetValue()) - } - return - } - } - t.Fatal("mooncode_http_requests_total was not collected") -} diff --git a/internal/observability/io.go b/internal/observability/io.go deleted file mode 100644 index 1ca3226..0000000 --- a/internal/observability/io.go +++ /dev/null @@ -1,132 +0,0 @@ -package observability - -import ( - "context" - "errors" - "io" - "time" - - gitcore "github.com/mooncode-ai/mooncode/pkg/git" - "github.com/mooncode-ai/mooncode/pkg/storage" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" -) - -func InstrumentGit(metrics *Metrics, provider trace.TracerProvider) gitcore.Wrapper { - return func(next gitcore.Client) gitcore.Client { - return &gitClient{next: next, metrics: metrics, tracer: provider.Tracer("github.com/mooncode-ai/mooncode/pkg/git")} - } -} - -type gitClient struct { - next gitcore.Client - metrics *Metrics - tracer trace.Tracer -} - -func (c *gitClient) Probe(ctx context.Context, request gitcore.ProbeRequest) error { - ctx, span := c.tracer.Start(ctx, "git.probe", trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attribute.String("scm.provider", boundedProvider(request.Provider)))) - started := time.Now() - err := c.next.Probe(ctx, request) - c.finish(span, "probe", request.Provider, started, err) - return err -} - -func (c *gitClient) Fetch(ctx context.Context, request gitcore.FetchRequest) (gitcore.FetchResult, error) { - ctx, span := c.tracer.Start(ctx, "git.fetch", trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attribute.String("scm.provider", boundedProvider(request.Provider)))) - started := time.Now() - result, err := c.next.Fetch(ctx, request) - c.finish(span, "fetch", request.Provider, started, err) - return result, err -} - -func (c *gitClient) finish(span trace.Span, operation, provider string, started time.Time, err error) { - result := operationResult(err) - c.metrics.ObserveGit(operation, provider, result, time.Since(started).Seconds()) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, result) - } - span.End() -} - -func InstrumentStorage(backend string, metrics *Metrics, provider trace.TracerProvider) storage.Wrapper { - return func(next storage.Store) storage.Store { - return &storageStore{next: next, backend: oneOf(backend, "s3", "filesystem"), metrics: metrics, tracer: provider.Tracer("github.com/mooncode-ai/mooncode/pkg/storage")} - } -} - -type storageStore struct { - next storage.Store - backend string - metrics *Metrics - tracer trace.Tracer -} - -func (s *storageStore) Put(ctx context.Context, location storage.Location, body io.Reader, size int64, opts ...storage.PutOption) (storage.Metadata, error) { - ctx, finish := s.start(ctx, "put") - metadata, err := s.next.Put(ctx, location, body, size, opts...) - finish(err) - return metadata, err -} - -func (s *storageStore) Open(ctx context.Context, location storage.Location, opts ...storage.OpenOption) (io.ReadCloser, error) { - ctx, finish := s.start(ctx, "open") - reader, err := s.next.Open(ctx, location, opts...) - finish(err) - return reader, err -} - -func (s *storageStore) Stat(ctx context.Context, location storage.Location) (storage.Metadata, error) { - ctx, finish := s.start(ctx, "stat") - metadata, err := s.next.Stat(ctx, location) - finish(err) - return metadata, err -} - -func (s *storageStore) Delete(ctx context.Context, location storage.Location) error { - ctx, finish := s.start(ctx, "delete") - err := s.next.Delete(ctx, location) - finish(err) - return err -} - -func (s *storageStore) start(ctx context.Context, operation string) (context.Context, func(error)) { - ctx, span := s.tracer.Start(ctx, "storage."+operation, trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes(attribute.String("storage.backend", s.backend), attribute.String("storage.operation", operation))) - started := time.Now() - return ctx, func(err error) { - result := storageResult(err) - s.metrics.ObserveStorage(s.backend, operation, result, time.Since(started).Seconds()) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, result) - } - span.End() - } -} - -func operationResult(err error) string { - if err == nil { - return "success" - } - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return "cancelled" - } - return "error" -} - -func storageResult(err error) string { - if errors.Is(err, storage.ErrNotFound) { - return "not_found" - } - if errors.Is(err, storage.ErrAlreadyExists) { - return "conflict" - } - return operationResult(err) -} - -func boundedProvider(provider string) string { return oneOf(provider, "github", "gitlab", "generic") } - -var _ gitcore.Client = (*gitClient)(nil) -var _ storage.Store = (*storageStore)(nil) diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go deleted file mode 100644 index b7f5594..0000000 --- a/internal/observability/metrics.go +++ /dev/null @@ -1,101 +0,0 @@ -package observability - -import "github.com/prometheus/client_golang/prometheus" - -type Metrics struct { - requests *prometheus.CounterVec - httpDuration *prometheus.HistogramVec - rateLimited *prometheus.CounterVec - jobs *prometheus.CounterVec - jobDuration *prometheus.HistogramVec - jobQueueDepth *prometheus.GaugeVec - channelRuntime *prometheus.GaugeVec - channelReconnects *prometheus.CounterVec - channelMessages *prometheus.CounterVec - gitOperations *prometheus.CounterVec - gitDuration *prometheus.HistogramVec - storageOperations *prometheus.CounterVec - storageDuration *prometheus.HistogramVec - outboxBacklog prometheus.Gauge -} - -func NewMetrics(registerer prometheus.Registerer) *Metrics { - metrics := &Metrics{ - requests: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Subsystem: "http", Name: "requests_total", Help: "Total application HTTP requests."}, []string{"method", "route", "status_class"}), - httpDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Namespace: "mooncode", Subsystem: "http", Name: "request_duration_seconds", Help: "Application HTTP request duration in seconds.", Buckets: prometheus.DefBuckets}, []string{"method", "route", "status_class"}), - rateLimited: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "rate_limited_total", Help: "Total requests rejected by local rate limits."}, []string{"scope", "operation"}), - jobs: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "jobs_total", Help: "Total completed background job attempts."}, []string{"type", "result"}), - jobDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Namespace: "mooncode", Name: "job_duration_seconds", Help: "Background job attempt duration in seconds.", Buckets: prometheus.DefBuckets}, []string{"type", "result"}), - jobQueueDepth: prometheus.NewGaugeVec(prometheus.GaugeOpts{Namespace: "mooncode", Name: "job_queue_depth", Help: "Current background job queue depth."}, []string{"type", "status"}), - channelRuntime: prometheus.NewGaugeVec(prometheus.GaugeOpts{Namespace: "mooncode", Name: "channel_runtime", Help: "Current channel runtimes by state."}, []string{"type", "state"}), - channelReconnects: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "channel_reconnects_total", Help: "Total channel runtime reconnect outcomes."}, []string{"type", "result"}), - channelMessages: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "channel_messages_total", Help: "Total normalized channel messages."}, []string{"type", "result"}), - gitOperations: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "git_operations_total", Help: "Total Git operations."}, []string{"operation", "provider", "result"}), - gitDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Namespace: "mooncode", Name: "git_operation_duration_seconds", Help: "Git operation duration in seconds.", Buckets: prometheus.DefBuckets}, []string{"operation", "provider", "result"}), - storageOperations: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "storage_operations_total", Help: "Total binary storage operations."}, []string{"backend", "operation", "result"}), - storageDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Namespace: "mooncode", Name: "storage_operation_duration_seconds", Help: "Binary storage operation duration in seconds.", Buckets: prometheus.DefBuckets}, []string{"backend", "operation", "result"}), - outboxBacklog: prometheus.NewGauge(prometheus.GaugeOpts{Namespace: "mooncode", Name: "outbox_backlog", Help: "Current unpublished outbox event count."}), - } - registerer.MustRegister( - metrics.requests, metrics.httpDuration, metrics.rateLimited, metrics.jobs, metrics.jobDuration, - metrics.jobQueueDepth, metrics.channelRuntime, metrics.channelReconnects, metrics.channelMessages, - metrics.gitOperations, metrics.gitDuration, metrics.storageOperations, metrics.storageDuration, - metrics.outboxBacklog, - ) - return metrics -} - -func (m *Metrics) ObserveRateLimited(scope, operation string) { - m.rateLimited.WithLabelValues(oneOf(scope, "ip", "user", "workspace"), oneOf(operation, "read", "write")).Inc() -} - -func (m *Metrics) Observe(method, route, statusClass string, seconds float64) { - m.requests.WithLabelValues(method, route, statusClass).Inc() - m.httpDuration.WithLabelValues(method, route, statusClass).Observe(seconds) -} - -func (m *Metrics) ObserveJob(jobType, result string, seconds float64) { - jobType = oneOf(jobType, "repository.sync") - result = oneOf(result, "success", "retry", "failed", "lease_lost") - m.jobs.WithLabelValues(jobType, result).Inc() - m.jobDuration.WithLabelValues(jobType, result).Observe(seconds) -} - -func (m *Metrics) SetJobQueueDepth(jobType, status string, depth float64) { - m.jobQueueDepth.WithLabelValues(oneOf(jobType, "repository.sync"), oneOf(status, "queued", "running")).Set(depth) -} - -func (m *Metrics) AddChannelRuntime(channelType, state string, delta float64) { - m.channelRuntime.WithLabelValues(oneOf(channelType, "feishu", "dingtalk"), oneOf(state, "running", "retry_wait", "misconfigured")).Add(delta) -} - -func (m *Metrics) ObserveChannelReconnect(channelType, result string) { - m.channelReconnects.WithLabelValues(oneOf(channelType, "feishu", "dingtalk"), oneOf(result, "connected", "failed", "disconnected")).Inc() -} - -func (m *Metrics) ObserveChannelMessage(channelType, result string) { - m.channelMessages.WithLabelValues(oneOf(channelType, "feishu", "dingtalk"), oneOf(result, "accepted", "duplicate", "rejected", "failed")).Inc() -} - -func (m *Metrics) ObserveGit(operation, provider, result string, seconds float64) { - labels := []string{oneOf(operation, "probe", "fetch"), oneOf(provider, "github", "gitlab", "generic"), oneOf(result, "success", "error", "cancelled")} - m.gitOperations.WithLabelValues(labels...).Inc() - m.gitDuration.WithLabelValues(labels...).Observe(seconds) -} - -func (m *Metrics) ObserveStorage(backend, operation, result string, seconds float64) { - labels := []string{oneOf(backend, "s3", "filesystem"), oneOf(operation, "put", "open", "stat", "delete"), oneOf(result, "success", "not_found", "conflict", "error", "cancelled")} - m.storageOperations.WithLabelValues(labels...).Inc() - m.storageDuration.WithLabelValues(labels...).Observe(seconds) -} - -func (m *Metrics) SetOutboxBacklog(depth float64) { m.outboxBacklog.Set(depth) } - -func oneOf(value string, allowed ...string) string { - for _, candidate := range allowed { - if value == candidate { - return value - } - } - return "other" -} diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go deleted file mode 100644 index 5c9fed9..0000000 --- a/internal/observability/metrics_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package observability - -import ( - "testing" - - "github.com/prometheus/client_golang/prometheus" - "github.com/stretchr/testify/require" -) - -func TestMetricLabelsBoundUnexpectedValues(t *testing.T) { - registry := prometheus.NewRegistry() - metrics := NewMetrics(registry) - dynamicID := "019fa817-7784-7958-bd49-cc551ebc8173" - metrics.ObserveGit("fetch", dynamicID, "success", 0.01) - metrics.ObserveChannelMessage(dynamicID, "accepted") - metrics.ObserveStorage(dynamicID, dynamicID, "error", 0.01) - - families, err := registry.Gather() - require.NoError(t, err) - for _, family := range families { - for _, metric := range family.Metric { - for _, label := range metric.Label { - require.NotEqual(t, dynamicID, label.GetValue(), family.GetName()) - } - } - } -} - -func TestOneOfNeverPassesThroughUnknownValue(t *testing.T) { - require.Equal(t, "ready", oneOf("ready", "ready", "failed")) - require.Equal(t, "other", oneOf("tenant-controlled", "ready", "failed")) -} diff --git a/internal/observability/providers.go b/internal/observability/providers.go deleted file mode 100644 index 664d949..0000000 --- a/internal/observability/providers.go +++ /dev/null @@ -1,132 +0,0 @@ -package observability - -import ( - "context" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/prometheus/client_golang/prometheus" - "github.com/rs/zerolog" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/sdk/resource" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - "go.opentelemetry.io/otel/trace" - "go.opentelemetry.io/otel/trace/noop" - "go.uber.org/dig" -) - -const ServiceName = "mooncode-backend" - -type ServiceInfo struct { - Version string - Commit string - Environment string - InstanceID string -} - -type Shutdowner interface { - Shutdown(context.Context) error -} - -type Dependencies struct { - dig.Out - - Logger zerolog.Logger - Registry *prometheus.Registry - Metrics *Metrics - TracerProvider trace.TracerProvider - Propagator propagation.TextMapPropagator - Shutdowner Shutdowner -} - -func NewDependencies(cfg config.Config, info ServiceInfo) (Dependencies, error) { - logger, err := newLogger(cfg.Log, info) - if err != nil { - return Dependencies{}, err - } - - registry := prometheus.NewRegistry() - metrics := NewMetrics(registry) - // Baggage remains disabled until an explicit key allowlist is configured. - propagator := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}) - - tracerProvider, shutdowner, err := newTracerProvider(context.Background(), cfg.Observability.Tracing, info) - if err != nil { - return Dependencies{}, err - } - - return Dependencies{ - Logger: logger, - Registry: registry, - Metrics: metrics, - TracerProvider: tracerProvider, - Propagator: propagator, - Shutdowner: shutdowner, - }, nil -} - -func newLogger(cfg config.LogConfig, info ServiceInfo) (zerolog.Logger, error) { - level, err := zerolog.ParseLevel(strings.ToLower(cfg.Level)) - if err != nil { - return zerolog.Logger{}, fmt.Errorf("parse log level: %w", err) - } - - var writer io.Writer = os.Stdout - if cfg.Format == "console" { - writer = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339} - } - - logger := zerolog.New(writer).Level(level).With(). - Timestamp(). - Str("service_name", ServiceName). - Str("service_version", info.Version). - Str("service_commit", info.Commit). - Str("service_instance_id", info.InstanceID). - Str("deployment_environment", info.Environment). - Logger() - return logger, nil -} - -func newTracerProvider( - ctx context.Context, - cfg config.TracingConfig, - info ServiceInfo, -) (trace.TracerProvider, Shutdowner, error) { - if !cfg.Enabled { - return noop.NewTracerProvider(), shutdownFunc(func(context.Context) error { return nil }), nil - } - - exporter, err := otlptracehttp.New(ctx, - otlptracehttp.WithEndpointURL(cfg.Endpoint), - otlptracehttp.WithTimeout(cfg.ExporterTimeout), - ) - if err != nil { - return nil, nil, fmt.Errorf("create OTLP trace exporter: %w", err) - } - - res := resource.NewWithAttributes("", - attribute.String("service.name", ServiceName), - attribute.String("service.version", info.Version), - attribute.String("service.commit", info.Commit), - attribute.String("service.instance.id", info.InstanceID), - attribute.String("deployment.environment", info.Environment), - ) - provider := sdktrace.NewTracerProvider( - sdktrace.WithBatcher(exporter), - sdktrace.WithResource(res), - sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.SampleRatio))), - ) - return provider, shutdownFunc(provider.Shutdown), nil -} - -type shutdownFunc func(context.Context) error - -func (function shutdownFunc) Shutdown(ctx context.Context) error { - return function(ctx) -} diff --git a/internal/outbox/dispatcher.go b/internal/outbox/dispatcher.go deleted file mode 100644 index d02cb6d..0000000 --- a/internal/outbox/dispatcher.go +++ /dev/null @@ -1,152 +0,0 @@ -// Package outbox delivers committed domain events from PostgreSQL. -package outbox - -import ( - "context" - "encoding/json" - "errors" - "sync/atomic" - "time" - - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/rs/zerolog" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" -) - -type Publisher interface { - Publish(ctx context.Context, event model.OutboxEvent) error -} - -type PublisherFunc func(context.Context, model.OutboxEvent) error - -func (function PublisherFunc) Publish(ctx context.Context, event model.OutboxEvent) error { - return function(ctx, event) -} - -func NewLocalPublisher() Publisher { - return PublisherFunc(func(context.Context, model.OutboxEvent) error { return nil }) -} - -type Observer interface{ SetOutboxBacklog(float64) } -type Option func(*Dispatcher) - -func WithClock(clock func() time.Time) Option { - return func(dispatcher *Dispatcher) { - if clock != nil { - dispatcher.clock = clock - } - } -} - -type Dispatcher struct { - store repository.OutboxStore - publisher Publisher - owner string - cfg config.OutboxConfig - logger zerolog.Logger - observer Observer - tracer trace.Tracer - propagator propagation.TextMapPropagator - clock func() time.Time - ready atomic.Bool -} - -func New(store repository.OutboxStore, publisher Publisher, owner string, cfg config.Config, logger zerolog.Logger, observer Observer, provider trace.TracerProvider, propagator propagation.TextMapPropagator, opts ...Option) (*Dispatcher, error) { - if store == nil || publisher == nil || owner == "" || observer == nil || provider == nil || propagator == nil { - return nil, errors.New("outbox dispatcher dependencies are required") - } - dispatcher := &Dispatcher{ - store: store, publisher: publisher, owner: owner, cfg: cfg.Outbox, logger: logger, observer: observer, - tracer: provider.Tracer("github.com/mooncode-ai/mooncode/internal/outbox"), propagator: propagator, clock: time.Now, - } - for _, option := range opts { - if option != nil { - option(dispatcher) - } - } - return dispatcher, nil -} - -func (d *Dispatcher) Ready() bool { return d.ready.Load() } - -func (d *Dispatcher) Run(ctx context.Context) error { - ticker := time.NewTicker(d.cfg.PollInterval) - defer ticker.Stop() - for { - if _, err := d.Sweep(ctx); err != nil && !errors.Is(err, context.Canceled) { - d.ready.Store(false) - d.logger.Warn().Err(err).Str("operation", "outbox.sweep").Msg("outbox sweep failed") - } else if ctx.Err() == nil { - d.ready.Store(true) - } - select { - case <-ctx.Done(): - d.ready.Store(false) - return nil - case <-ticker.C: - } - } -} - -func (d *Dispatcher) Sweep(ctx context.Context) (int, error) { - backlog, err := d.store.CountOutboxBacklog(ctx) - if err != nil { - return 0, err - } - d.observer.SetOutboxBacklog(float64(backlog)) - processed := 0 - for processed < d.cfg.BatchSize { - now := d.clock().UTC() - event, err := d.store.ClaimOutboxEvent(ctx, d.owner, now.Add(d.cfg.LeaseDuration)) - if errors.Is(err, repository.ErrNotFound) { - return processed, nil - } - if err != nil { - return processed, err - } - processed++ - if err := d.publish(ctx, event); err != nil { - return processed, err - } - } - return processed, nil -} - -func (d *Dispatcher) publish(parent context.Context, event model.OutboxEvent) error { - links := linksFromTraceContext(d.propagator, event.TraceContext) - ctx, span := d.tracer.Start(parent, "outbox.publish", trace.WithSpanKind(trace.SpanKindConsumer), trace.WithLinks(links...)) - defer span.End() - ctx, cancel := context.WithTimeout(ctx, d.cfg.PublishTimeout) - err := d.publisher.Publish(ctx, event) - cancel() - if err == nil { - if completeErr := d.store.CompleteOutboxEvent(parent, event); completeErr != nil { - return completeErr - } - return nil - } - span.RecordError(err) - span.SetStatus(codes.Error, "publish failed") - delay := d.cfg.RetryBaseDelay * time.Duration(1< d.cfg.RetryMaxDelay { - delay = d.cfg.RetryMaxDelay - } - return d.store.RetryOutboxEvent(parent, event, d.clock().UTC().Add(delay), "outbox.publish_failed", "Event publication failed") -} - -func linksFromTraceContext(propagator propagation.TextMapPropagator, encoded json.RawMessage) []trace.Link { - carrier := propagation.MapCarrier{} - if len(encoded) == 0 || json.Unmarshal(encoded, &carrier) != nil { - return nil - } - ctx := propagator.Extract(context.Background(), carrier) - spanContext := trace.SpanContextFromContext(ctx) - if !spanContext.IsValid() { - return nil - } - return []trace.Link{{SpanContext: spanContext}} -} diff --git a/internal/outbox/dispatcher_test.go b/internal/outbox/dispatcher_test.go deleted file mode 100644 index 95c02b4..0000000 --- a/internal/outbox/dispatcher_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package outbox - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace/noop" -) - -func TestDispatcherCompletesPublishedEvent(t *testing.T) { - now := time.Unix(1000, 0) - event := model.OutboxEvent{ID: uuid.New(), Type: "repository.synced", Attempt: 1} - store := &fakeStore{events: []model.OutboxEvent{event}, backlog: 1} - observer := &fakeObserver{} - dispatcher := newTestDispatcher(t, store, NewLocalPublisher(), observer, now) - - processed, err := dispatcher.Sweep(context.Background()) - require.NoError(t, err) - require.Equal(t, 1, processed) - require.Equal(t, []uuid.UUID{event.ID}, store.completed) - require.Equal(t, float64(1), observer.backlog) -} - -func TestDispatcherRetriesPublisherFailure(t *testing.T) { - now := time.Unix(1000, 0) - event := model.OutboxEvent{ID: uuid.New(), Type: "im.message_received", Attempt: 3} - store := &fakeStore{events: []model.OutboxEvent{event}, backlog: 1} - publisher := PublisherFunc(func(context.Context, model.OutboxEvent) error { return errors.New("unavailable") }) - dispatcher := newTestDispatcher(t, store, publisher, &fakeObserver{}, now) - - processed, err := dispatcher.Sweep(context.Background()) - require.NoError(t, err) - require.Equal(t, 1, processed) - require.Equal(t, now.Add(4*time.Second).UTC(), store.retried[0]) -} - -func newTestDispatcher(t *testing.T, store *fakeStore, publisher Publisher, observer Observer, now time.Time) *Dispatcher { - t.Helper() - cfg := config.Config{Outbox: config.OutboxConfig{ - PollInterval: time.Second, BatchSize: 10, LeaseDuration: 30 * time.Second, PublishTimeout: 10 * time.Second, - RetryBaseDelay: time.Second, RetryMaxDelay: time.Minute, - }} - dispatcher, err := New(store, publisher, "instance", cfg, zerolog.Nop(), observer, noop.NewTracerProvider(), propagation.TraceContext{}, WithClock(func() time.Time { return now })) - require.NoError(t, err) - return dispatcher -} - -type fakeObserver struct{ backlog float64 } - -func (o *fakeObserver) SetOutboxBacklog(value float64) { o.backlog = value } - -type fakeStore struct { - events []model.OutboxEvent - completed []uuid.UUID - retried []time.Time - backlog int64 -} - -func (f *fakeStore) ClaimOutboxEvent(_ context.Context, owner string, leaseUntil time.Time) (model.OutboxEvent, error) { - if len(f.events) == 0 { - return model.OutboxEvent{}, repository.ErrNotFound - } - event := f.events[0] - f.events = f.events[1:] - event.LeaseOwner, event.LeaseUntil = owner, &leaseUntil - return event, nil -} -func (f *fakeStore) CompleteOutboxEvent(_ context.Context, event model.OutboxEvent) error { - f.completed = append(f.completed, event.ID) - return nil -} -func (f *fakeStore) RetryOutboxEvent(_ context.Context, _ model.OutboxEvent, next time.Time, _, _ string) error { - f.retried = append(f.retried, next) - return nil -} -func (f *fakeStore) CountOutboxBacklog(context.Context) (int64, error) { return f.backlog, nil } diff --git a/internal/platform/audit/audit_integration_test.go b/internal/platform/audit/audit_integration_test.go new file mode 100644 index 0000000..2928069 --- /dev/null +++ b/internal/platform/audit/audit_integration_test.go @@ -0,0 +1,47 @@ +//go:build integration + +package audit + +import ( + "context" + "os" + "testing" + + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" +) + +func TestAuditEventRollsBackWithFailedTransaction(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + action := "integration.transaction_rollback." + uuid.NewString() + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := Record(ctx, sqlc.New(tx), Event{Action: action, Resource: "integration_test"}); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, "SELECT 1/0"); err == nil { + t.Fatal("expected transaction to enter a failed state") + } + if err := tx.Commit(ctx); err == nil { + t.Fatal("expected failed transaction commit to fail") + } + + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_events WHERE action=$1`, action).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("failed transaction left %d orphan audit events", count) + } +} diff --git a/internal/platform/audit/metadata.go b/internal/platform/audit/metadata.go new file mode 100644 index 0000000..f5a860d --- /dev/null +++ b/internal/platform/audit/metadata.go @@ -0,0 +1,79 @@ +package audit + +import ( + "encoding/json" + "fmt" + "strings" + "unicode" +) + +var forbiddenMetadataKeys = map[string]struct{}{ + "accesstoken": {}, + "appsecret": {}, + "authorization": {}, + "ciphertext": {}, + "clientsecret": {}, + "credential": {}, + "credentials": {}, + "nonce": {}, + "password": {}, + "pat": {}, + "rawresponse": {}, + "refreshtoken": {}, + "response": {}, + "secret": {}, + "token": {}, +} + +func marshalMetadata(metadata map[string]any) ([]byte, error) { + if metadata == nil { + return []byte("{}"), nil + } + + encoded, err := json.Marshal(metadata) + if err != nil { + return nil, fmt.Errorf("marshal audit metadata: %w", err) + } + + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + return nil, fmt.Errorf("validate audit metadata: %w", err) + } + if err := rejectSensitiveKeys(decoded); err != nil { + return nil, err + } + + return encoded, nil +} + +func rejectSensitiveKeys(value any) error { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + if _, forbidden := forbiddenMetadataKeys[normalizeKey(key)]; forbidden { + return fmt.Errorf("audit metadata key %q is sensitive", key) + } + if err := rejectSensitiveKeys(child); err != nil { + return err + } + } + case []any: + for _, child := range typed { + if err := rejectSensitiveKeys(child); err != nil { + return err + } + } + } + + return nil +} + +func normalizeKey(key string) string { + return strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return unicode.ToLower(r) + } + + return -1 + }, key) +} diff --git a/internal/platform/audit/model.go b/internal/platform/audit/model.go new file mode 100644 index 0000000..8602fdc --- /dev/null +++ b/internal/platform/audit/model.go @@ -0,0 +1,45 @@ +package audit + +import ( + "github.com/google/uuid" +) + +const ( + ActionProviderConnectionCreated = "provider_connection.created" + ActionProviderCredentialRotated = "provider_connection.credential_rotated" + ActionProviderConnectionDefaultSet = "provider_connection.default_set" + ActionProviderConnectionRevoked = "provider_connection.revoked" + ActionRepositoryProvisionRequested = "repository.provision_requested" + ActionRepositorySourceUpdateRequested = "repository.source_update_requested" + ActionRepositoryRefreshRequested = "repository.refresh_requested" + ActionRepositoryDeletionRequested = "repository.deletion_requested" + ActionRepositoryOperationCancelled = "repository.operation_cancelled" + ActionRepositoryPurgeRequeued = "repository.purge_requeued" + ActionAnalysisCreated = "analysis.created" + ActionAnalysisProfileCreated = "analysis_profile.created" + ActionAnalysisProfileVersionCreated = "analysis_profile.version_created" + ActionAnalysisProfileArchived = "analysis_profile.archived" + ActionChannelCreated = "channel.created" + ActionChannelConfigurationUpdated = "channel.configuration_updated" + ActionChannelCredentialRotated = "channel.credential_rotated" + ActionChannelEnabled = "channel.enabled" + ActionChannelDisabled = "channel.disabled" + ActionChannelDeleted = "channel.deleted" +) + +const ( + ResourceProviderConnection = "provider_connection" + ResourceRepository = "repository" + ResourceAnalysisRun = "analysis_run" + ResourceAnalysisProfile = "analysis_profile" + ResourceChannel = "channel" +) + +type Event struct { + WorkspaceID uuid.UUID + ActorUserID uuid.UUID + Action string + Resource string + ResourceID uuid.UUID + Metadata map[string]any +} diff --git a/internal/platform/audit/writer.go b/internal/platform/audit/writer.go new file mode 100644 index 0000000..cd7d3e2 --- /dev/null +++ b/internal/platform/audit/writer.go @@ -0,0 +1,45 @@ +package audit + +import ( + "context" + "fmt" + "strings" + + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/google/uuid" +) + +type Writer interface { + CreateAuditEvent(context.Context, sqlc.CreateAuditEventParams) error +} + +func Record(ctx context.Context, writer Writer, event Event) error { + if writer == nil { + return fmt.Errorf("audit writer is required") + } + if strings.TrimSpace(event.Action) == "" { + return fmt.Errorf("audit action is required") + } + if strings.TrimSpace(event.Resource) == "" { + return fmt.Errorf("audit resource type is required") + } + + metadata, err := marshalMetadata(event.Metadata) + if err != nil { + return err + } + + return writer.CreateAuditEvent(ctx, sqlc.CreateAuditEventParams{ + ID: uuid.New(), + WorkspaceID: nullableUUID(event.WorkspaceID), + ActorUserID: nullableUUID(event.ActorUserID), + Action: event.Action, + ResourceType: event.Resource, + ResourceID: nullableUUID(event.ResourceID), + Metadata: metadata, + }) +} + +func nullableUUID(value uuid.UUID) uuid.NullUUID { + return uuid.NullUUID{UUID: value, Valid: value != uuid.Nil} +} diff --git a/internal/platform/audit/writer_test.go b/internal/platform/audit/writer_test.go new file mode 100644 index 0000000..3ee442e --- /dev/null +++ b/internal/platform/audit/writer_test.go @@ -0,0 +1,67 @@ +package audit + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/google/uuid" +) + +type recordingWriter struct { + params sqlc.CreateAuditEventParams +} + +func (w *recordingWriter) CreateAuditEvent(_ context.Context, params sqlc.CreateAuditEventParams) error { + w.params = params + + return nil +} + +func TestRecordPreservesAuditIdentityAndSafeMetadata(t *testing.T) { + workspaceID, actorID, resourceID := uuid.New(), uuid.New(), uuid.New() + writer := &recordingWriter{} + + err := Record(context.Background(), writer, Event{ + WorkspaceID: workspaceID, + ActorUserID: actorID, + Action: ActionRepositoryRefreshRequested, + Resource: ResourceRepository, + ResourceID: resourceID, + Metadata: map[string]any{"operationId": uuid.New(), "credentialVersion": int64(4)}, + }) + if err != nil { + t.Fatal(err) + } + if !writer.params.WorkspaceID.Valid || writer.params.WorkspaceID.UUID != workspaceID || + !writer.params.ActorUserID.Valid || writer.params.ActorUserID.UUID != actorID || + !writer.params.ResourceID.Valid || writer.params.ResourceID.UUID != resourceID { + t.Fatalf("unexpected audit identity: %+v", writer.params) + } + var metadata map[string]any + if err := json.Unmarshal(writer.params.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if metadata["operationId"] == nil || metadata["credentialVersion"] != float64(4) { + t.Fatalf("unexpected metadata: %s", writer.params.Metadata) + } +} + +func TestRecordRejectsSensitiveMetadataAtAnyDepth(t *testing.T) { + tests := []map[string]any{ + {"token": "github_pat_secret"}, + {"nested": map[string]any{"app_secret": "secret"}}, + {"items": []any{map[string]any{"Authorization": "Bearer secret"}}}, + {"payload": json.RawMessage(`{"clientSecret":"secret"}`)}, + } + for _, metadata := range tests { + err := Record(context.Background(), &recordingWriter{}, Event{ + Action: ActionProviderConnectionCreated, Resource: ResourceProviderConnection, Metadata: metadata, + }) + if err == nil || !strings.Contains(err.Error(), "sensitive") { + t.Fatalf("Record(%v) error = %v, want sensitive metadata rejection", metadata, err) + } + } +} diff --git a/internal/platform/auth/actor.go b/internal/platform/auth/actor.go new file mode 100644 index 0000000..51f4aaf --- /dev/null +++ b/internal/platform/auth/actor.go @@ -0,0 +1,28 @@ +package auth + +import ( + "context" + + "github.com/google/uuid" +) + +type Actor struct { + UserID uuid.UUID + Subject string + DisplayName string + Username string + Email string + Status string +} + +type actorKey struct{} + +func WithActor(ctx context.Context, actor Actor) context.Context { + return context.WithValue(ctx, actorKey{}, actor) +} + +func ActorFrom(ctx context.Context) (Actor, bool) { + actor, ok := ctx.Value(actorKey{}).(Actor) + + return actor, ok +} diff --git a/internal/platform/auth/csrf.go b/internal/platform/auth/csrf.go new file mode 100644 index 0000000..43eb5ac --- /dev/null +++ b/internal/platform/auth/csrf.go @@ -0,0 +1,68 @@ +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +type CSRF struct { + key []byte +} + +func NewCSRF(key []byte) *CSRF { + return &CSRF{key: append([]byte(nil), key...)} +} + +func (c *CSRF) Issue(actor Actor) (string, error) { + nonce := make([]byte, 24) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + payload := base64.RawURLEncoding.EncodeToString(nonce) + signature := c.sign(actor.Subject, payload) + + return payload + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +func (c *CSRF) Middleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + if ctx.Request.Method == http.MethodGet || ctx.Request.Method == http.MethodHead || ctx.Request.Method == http.MethodOptions { + ctx.Next() + return + } + actor, ok := ActorFrom(ctx.Request.Context()) + if !ok || !c.Valid(actor, ctx.GetHeader("X-CSRF-Token")) { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": gin.H{"code": "csrf.invalid", "message": "CSRF token is missing or invalid", "requestId": RequestID(ctx)}}) + return + } + ctx.Next() + } +} + +func (c *CSRF) Valid(actor Actor, token string) bool { + payload, encodedSignature, ok := strings.Cut(token, ".") + if !ok || payload == "" { + return false + } + signature, err := base64.RawURLEncoding.DecodeString(encodedSignature) + if err != nil { + return false + } + + return hmac.Equal(signature, c.sign(actor.Subject, payload)) +} + +func (c *CSRF) sign(subject, payload string) []byte { + mac := hmac.New(sha256.New, c.key) + _, _ = mac.Write([]byte(subject)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(payload)) + + return mac.Sum(nil) +} diff --git a/internal/platform/auth/headers.go b/internal/platform/auth/headers.go new file mode 100644 index 0000000..04382ab --- /dev/null +++ b/internal/platform/auth/headers.go @@ -0,0 +1,34 @@ +package auth + +import ( + "errors" + "net/http" + "strings" +) + +type Identity struct { + Subject string + DisplayName string + Username string + Email string +} + +func IdentityFromHeaders(header http.Header) (Identity, error) { + identity := Identity{ + Subject: strings.TrimSpace(header.Get("Remote-Sub")), + DisplayName: strings.TrimSpace(header.Get("Remote-Name")), + Username: strings.TrimSpace(header.Get("Remote-User")), + Email: strings.ToLower(strings.TrimSpace(header.Get("Remote-Email"))), + } + if identity.Subject == "" { + return Identity{}, errors.New("Remote-Sub is required") + } + if identity.Username == "" { + identity.Username = identity.Subject + } + if identity.DisplayName == "" { + identity.DisplayName = identity.Username + } + + return identity, nil +} diff --git a/internal/platform/auth/headers_test.go b/internal/platform/auth/headers_test.go new file mode 100644 index 0000000..67dde68 --- /dev/null +++ b/internal/platform/auth/headers_test.go @@ -0,0 +1,27 @@ +package auth + +import ( + "net/http" + "testing" +) + +func TestIdentityRequiresStableSubject(t *testing.T) { + header := http.Header{"Remote-User": []string{"octocat"}} + if _, err := IdentityFromHeaders(header); err == nil { + t.Fatal("expected Remote-Sub to be required") + } +} + +func TestCSRFBindsTokenToSubject(t *testing.T) { + guard := NewCSRF([]byte("01234567890123456789012345678901")) + token, err := guard.Issue(Actor{Subject: "github:1"}) + if err != nil { + t.Fatal(err) + } + if !guard.Valid(Actor{Subject: "github:1"}, token) { + t.Fatal("token should be valid") + } + if guard.Valid(Actor{Subject: "github:2"}, token) { + t.Fatal("token must not transfer between actors") + } +} diff --git a/internal/platform/auth/middleware.go b/internal/platform/auth/middleware.go new file mode 100644 index 0000000..4ee3368 --- /dev/null +++ b/internal/platform/auth/middleware.go @@ -0,0 +1,49 @@ +package auth + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" +) + +type Resolver interface { + Resolve(context.Context, Identity) (Actor, error) +} + +func Require(resolver Resolver, trusted *TrustedProxies) gin.HandlerFunc { + return func(c *gin.Context) { + if !trusted.Contains(c.Request.RemoteAddr) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "identity.untrusted_gateway", "message": "Authenticated identity must come from the configured gateway", "requestId": RequestID(c)}}) + return + } + + identity, err := IdentityFromHeaders(c.Request.Header) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "auth.required", "message": "Sign in with GitHub to continue", "requestId": RequestID(c)}}) + return + } + + actor, err := resolver.Resolve(c.Request.Context(), identity) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "auth.identity_failed", "message": "Unable to resolve authenticated identity", "requestId": RequestID(c)}}) + return + } + if actor.Status == "suspended" || actor.Status == "deleted" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": gin.H{"code": "account." + actor.Status, "message": "This account is not available", "requestId": RequestID(c)}}) + return + } + + c.Request = c.Request.WithContext(WithActor(c.Request.Context(), actor)) + c.Next() + } +} + +const requestIDKey = "request_id" + +func RequestID(c *gin.Context) string { + value, _ := c.Get(requestIDKey) + requestID, _ := value.(string) + + return requestID +} diff --git a/internal/platform/auth/proxy.go b/internal/platform/auth/proxy.go new file mode 100644 index 0000000..151e6e2 --- /dev/null +++ b/internal/platform/auth/proxy.go @@ -0,0 +1,50 @@ +package auth + +import ( + "fmt" + "net" + "strings" +) + +type TrustedProxies struct { + networks []*net.IPNet +} + +func NewTrustedProxies(values []string) (*TrustedProxies, error) { + if len(values) == 0 { + return nil, fmt.Errorf("at least one trusted identity proxy CIDR is required") + } + + trusted := &TrustedProxies{networks: make([]*net.IPNet, 0, len(values))} + for _, value := range values { + _, network, err := net.ParseCIDR(strings.TrimSpace(value)) + if err != nil { + return nil, fmt.Errorf("parse trusted identity proxy CIDR %q: %w", value, err) + } + trusted.networks = append(trusted.networks, network) + } + + return trusted, nil +} + +func (t *TrustedProxies) Contains(remoteAddress string) bool { + if t == nil { + return false + } + + host, _, err := net.SplitHostPort(remoteAddress) + if err != nil { + host = remoteAddress + } + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + return false + } + for _, network := range t.networks { + if network.Contains(ip) { + return true + } + } + + return false +} diff --git a/internal/platform/auth/proxy_test.go b/internal/platform/auth/proxy_test.go new file mode 100644 index 0000000..70b9010 --- /dev/null +++ b/internal/platform/auth/proxy_test.go @@ -0,0 +1,29 @@ +package auth + +import "testing" + +func TestTrustedProxiesMatchesOnlyDirectPeer(t *testing.T) { + trusted, err := NewTrustedProxies([]string{"127.0.0.1/32", "172.30.0.2/32"}) + if err != nil { + t.Fatal(err) + } + for _, address := range []string{"127.0.0.1:8080", "172.30.0.2:49152"} { + if !trusted.Contains(address) { + t.Fatalf("expected %q to be trusted", address) + } + } + for _, address := range []string{"172.30.0.3:49152", "invalid", ""} { + if trusted.Contains(address) { + t.Fatalf("expected %q to be rejected", address) + } + } +} + +func TestTrustedProxiesRejectsEmptyAndInvalidConfiguration(t *testing.T) { + if _, err := NewTrustedProxies(nil); err == nil { + t.Fatal("expected empty trust configuration to fail") + } + if _, err := NewTrustedProxies([]string{"not-a-cidr"}); err == nil { + t.Fatal("expected invalid trust configuration to fail") + } +} diff --git a/internal/platform/auth/request_id.go b/internal/platform/auth/request_id.go new file mode 100644 index 0000000..662337f --- /dev/null +++ b/internal/platform/auth/request_id.go @@ -0,0 +1,20 @@ +package auth + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func RequestIDMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := strings.TrimSpace(c.GetHeader("X-Request-ID")) + if requestID == "" || len(requestID) > 128 { + requestID = uuid.NewString() + } + c.Set(requestIDKey, requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} diff --git a/internal/platform/buildinfo/buildinfo.go b/internal/platform/buildinfo/buildinfo.go new file mode 100644 index 0000000..aa53b22 --- /dev/null +++ b/internal/platform/buildinfo/buildinfo.go @@ -0,0 +1,17 @@ +package buildinfo + +var ( + Version = "dev" + Commit = "unknown" + BuildTime = "unknown" +) + +type Info struct { + Version string + Commit string + BuildTime string +} + +func Current() Info { + return Info{Version: Version, Commit: Commit, BuildTime: BuildTime} +} diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go new file mode 100644 index 0000000..12a5de2 --- /dev/null +++ b/internal/platform/config/config.go @@ -0,0 +1,235 @@ +package config + +import ( + "encoding/base64" + "errors" + "fmt" + "net" + "strings" + "time" + + "github.com/spf13/viper" +) + +type Config struct { + HTTP HTTP + Database Database + Auth Auth + Secrets Secrets + Repository Repository + Hatchet Hatchet + Analysis Analysis + Metrics Metrics +} + +type HTTP struct { + Address string + MaxBodyBytes int64 + RequestTimeout time.Duration +} + +type Database struct { + URL string + MigrateOnStart bool + MaxConnections int32 +} + +type Auth struct { + Issuer string + AppURL string + LogoutURL string + RegistrationMode string + TermsVersion string + PrivacyVersion string + TrustedProxyCIDRs []string + CSRFKey []byte +} + +type Secrets struct { + Key []byte + KeyVersion int +} + +type Repository struct { + Root string + WorktreeMaxAge time.Duration + MaxMirrorBytes int64 + MaxPerWorkspace int64 +} + +type Hatchet struct { + Token string + Namespace string + Address string +} + +type Analysis struct { + SCCPath string + Timeout time.Duration + MaxOutputBytes int64 + MaxConcurrentPerWorkspace int64 +} + +type Metrics struct { + WorkerAddress string +} + +func Load(path string) (Config, error) { + v := viper.New() + v.SetEnvPrefix("MOONCODE") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + v.SetDefault("http.address", ":8080") + v.SetDefault("http.max_body_bytes", 1048576) + v.SetDefault("http.request_timeout", "30s") + v.SetDefault("database.max_connections", 20) + v.SetDefault("database.migrate_on_start", true) + v.SetDefault("auth.issuer", "tinyauth") + v.SetDefault("auth.app_url", "http://mooncode.localhost:3100/app") + v.SetDefault("auth.logout_url", "/api/user/logout") + v.SetDefault("auth.registration_mode", "public") + v.SetDefault("auth.terms_version", "2026-07-30") + v.SetDefault("auth.privacy_version", "2026-07-30") + v.SetDefault("auth.trusted_proxy_cidrs", []string{"127.0.0.1/32", "::1/128"}) + v.SetDefault("secrets.key_version", 1) + v.SetDefault("repository.root", "/var/lib/mooncode/repositories") + v.SetDefault("repository.worktree_max_age", "24h") + v.SetDefault("repository.max_mirror_bytes", 10737418240) + v.SetDefault("repository.max_per_workspace", 100) + v.SetDefault("hatchet.namespace", "mooncode") + v.SetDefault("analysis.scc_path", "scc") + v.SetDefault("analysis.timeout", "15m") + v.SetDefault("analysis.max_output_bytes", 16777216) + v.SetDefault("analysis.max_concurrent_per_workspace", 10) + v.SetDefault("metrics.worker_address", ":9090") + + if path != "" { + v.SetConfigFile(path) + if err := v.ReadInConfig(); err != nil { + return Config{}, fmt.Errorf("read config: %w", err) + } + } + + csrfKey, err := decodeKey(v.GetString("auth.csrf_key"), "auth.csrf_key") + if err != nil { + return Config{}, err + } + secretKey, err := decodeKey(v.GetString("secrets.key"), "secrets.key") + if err != nil { + return Config{}, err + } + databaseMaxConnections := v.GetInt64("database.max_connections") + if databaseMaxConnections <= 0 || databaseMaxConnections > 1<<31-1 { + return Config{}, errors.New("database.max_connections must be between 1 and 2147483647") + } + + cfg := Config{ + HTTP: HTTP{ + Address: v.GetString("http.address"), MaxBodyBytes: v.GetInt64("http.max_body_bytes"), + RequestTimeout: v.GetDuration("http.request_timeout"), + }, + Database: Database{ + URL: v.GetString("database.url"), MigrateOnStart: v.GetBool("database.migrate_on_start"), + MaxConnections: int32(databaseMaxConnections), + }, + Auth: Auth{ + Issuer: v.GetString("auth.issuer"), AppURL: v.GetString("auth.app_url"), + LogoutURL: v.GetString("auth.logout_url"), RegistrationMode: v.GetString("auth.registration_mode"), + TermsVersion: v.GetString("auth.terms_version"), PrivacyVersion: v.GetString("auth.privacy_version"), + TrustedProxyCIDRs: stringSlice(v.GetStringSlice("auth.trusted_proxy_cidrs")), CSRFKey: csrfKey, + }, + Secrets: Secrets{Key: secretKey, KeyVersion: v.GetInt("secrets.key_version")}, + Repository: Repository{Root: v.GetString("repository.root"), WorktreeMaxAge: v.GetDuration("repository.worktree_max_age"), MaxMirrorBytes: v.GetInt64("repository.max_mirror_bytes"), MaxPerWorkspace: v.GetInt64("repository.max_per_workspace")}, + Hatchet: Hatchet{Token: v.GetString("hatchet.token"), Namespace: v.GetString("hatchet.namespace"), Address: v.GetString("hatchet.address")}, + Analysis: Analysis{ + SCCPath: v.GetString("analysis.scc_path"), Timeout: v.GetDuration("analysis.timeout"), + MaxOutputBytes: v.GetInt64("analysis.max_output_bytes"), MaxConcurrentPerWorkspace: v.GetInt64("analysis.max_concurrent_per_workspace"), + }, + Metrics: Metrics{WorkerAddress: v.GetString("metrics.worker_address")}, + } + + if cfg.Database.URL == "" { + return Config{}, errors.New("database.url is required") + } + if strings.TrimSpace(cfg.HTTP.Address) == "" { + return Config{}, errors.New("http.address must not be empty") + } + if cfg.HTTP.MaxBodyBytes <= 0 { + return Config{}, errors.New("http.max_body_bytes must be greater than zero") + } + if cfg.HTTP.RequestTimeout <= 0 { + return Config{}, errors.New("http.request_timeout must be greater than zero") + } + if cfg.Analysis.MaxOutputBytes <= 0 { + return Config{}, errors.New("analysis.max_output_bytes must be greater than zero") + } + if strings.TrimSpace(cfg.Repository.Root) == "" { + return Config{}, errors.New("repository.root must not be empty") + } + if cfg.Repository.WorktreeMaxAge <= 0 { + return Config{}, errors.New("repository.worktree_max_age must be greater than zero") + } + if cfg.Repository.MaxMirrorBytes <= 0 { + return Config{}, errors.New("repository.max_mirror_bytes must be greater than zero") + } + if cfg.Repository.MaxPerWorkspace <= 0 { + return Config{}, errors.New("repository.max_per_workspace must be greater than zero") + } + if cfg.Analysis.MaxConcurrentPerWorkspace <= 0 { + return Config{}, errors.New("analysis.max_concurrent_per_workspace must be greater than zero") + } + if strings.TrimSpace(cfg.Analysis.SCCPath) == "" { + return Config{}, errors.New("analysis.scc_path must not be empty") + } + if cfg.Analysis.Timeout <= 0 { + return Config{}, errors.New("analysis.timeout must be greater than zero") + } + if cfg.Auth.RegistrationMode != "disabled" && cfg.Auth.RegistrationMode != "invite_only" && cfg.Auth.RegistrationMode != "public" { + return Config{}, errors.New("auth.registration_mode must be disabled, invite_only, or public") + } + if strings.TrimSpace(cfg.Metrics.WorkerAddress) == "" { + return Config{}, errors.New("metrics.worker_address must not be empty") + } + if len(cfg.Auth.CSRFKey) < 32 { + return Config{}, errors.New("auth.csrf_key must decode to at least 32 bytes") + } + if len(cfg.Auth.TrustedProxyCIDRs) == 0 { + return Config{}, errors.New("auth.trusted_proxy_cidrs must not be empty") + } + for _, value := range cfg.Auth.TrustedProxyCIDRs { + if _, _, err := net.ParseCIDR(value); err != nil { + return Config{}, fmt.Errorf("auth.trusted_proxy_cidrs contains invalid CIDR %q: %w", value, err) + } + } + if len(cfg.Secrets.Key) != 32 { + return Config{}, errors.New("secrets.key must decode to exactly 32 bytes") + } + if cfg.Secrets.KeyVersion <= 0 { + return Config{}, errors.New("secrets.key_version must be greater than zero") + } + + return cfg, nil +} + +func stringSlice(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + for _, item := range strings.Split(value, ",") { + if item = strings.TrimSpace(item); item != "" { + result = append(result, item) + } + } + } + + return result +} + +func decodeKey(value, name string) ([]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("%s must be base64: %w", name, err) + } + + return decoded, nil +} diff --git a/internal/platform/config/config_test.go b/internal/platform/config/config_test.go new file mode 100644 index 0000000..4fb69a0 --- /dev/null +++ b/internal/platform/config/config_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestLoadFromEnvironment(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode") + t.Setenv("MOONCODE_AUTH_APP_URL", "https://env.mooncode.example/app") + t.Setenv("MOONCODE_AUTH_TRUSTED_PROXY_CIDRS", "10.10.0.2/32,10.10.0.3/32") + t.Setenv("MOONCODE_AUTH_CSRF_KEY", key) + t.Setenv("MOONCODE_SECRETS_KEY", key) + + cfg, err := Load("") + if err != nil { + t.Fatal(err) + } + if cfg.Repository.Root == "" || cfg.Repository.MaxMirrorBytes <= 0 || cfg.Repository.MaxPerWorkspace <= 0 || cfg.Analysis.MaxConcurrentPerWorkspace <= 0 || cfg.Metrics.WorkerAddress != ":9090" || cfg.Database.URL == "" || cfg.Auth.AppURL != "https://env.mooncode.example/app" { + t.Fatal("expected defaults and database URL") + } + if len(cfg.Auth.TrustedProxyCIDRs) != 2 || cfg.Auth.TrustedProxyCIDRs[0] != "10.10.0.2/32" { + t.Fatalf("trusted proxy CIDRs = %v", cfg.Auth.TrustedProxyCIDRs) + } + if cfg.Auth.LogoutURL != "/api/user/logout" { + t.Fatalf("auth logout URL = %q", cfg.Auth.LogoutURL) + } +} + +func TestLoadRejectsEmptyWorkerMetricsAddress(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode") + t.Setenv("MOONCODE_AUTH_CSRF_KEY", key) + t.Setenv("MOONCODE_SECRETS_KEY", key) + t.Setenv("MOONCODE_METRICS_WORKER_ADDRESS", " ") + + if _, err := Load(""); err == nil || err.Error() != "metrics.worker_address must not be empty" { + t.Fatalf("Load() error = %v", err) + } +} + +func TestLoadRejectsNonPositiveWorkspaceQuotas(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + for variable, want := range map[string]string{ + "MOONCODE_REPOSITORY_MAX_PER_WORKSPACE": "repository.max_per_workspace must be greater than zero", + "MOONCODE_ANALYSIS_MAX_CONCURRENT_PER_WORKSPACE": "analysis.max_concurrent_per_workspace must be greater than zero", + } { + t.Run(variable, func(t *testing.T) { + t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode") + t.Setenv("MOONCODE_AUTH_CSRF_KEY", key) + t.Setenv("MOONCODE_SECRETS_KEY", key) + t.Setenv(variable, "0") + + if _, err := Load(""); err == nil || err.Error() != want { + t.Fatalf("Load() error = %v", err) + } + }) + } +} + +func TestLoadRejectsInvalidOperationalLimits(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + tests := []struct { + name string + variable string + value string + want string + }{ + {name: "database connections zero", variable: "MOONCODE_DATABASE_MAX_CONNECTIONS", value: "0", want: "database.max_connections must be between 1 and 2147483647"}, + {name: "database connections overflow", variable: "MOONCODE_DATABASE_MAX_CONNECTIONS", value: "2147483648", want: "database.max_connections must be between 1 and 2147483647"}, + {name: "analysis timeout", variable: "MOONCODE_ANALYSIS_TIMEOUT", value: "0s", want: "analysis.timeout must be greater than zero"}, + {name: "analysis binary", variable: "MOONCODE_ANALYSIS_SCC_PATH", value: " ", want: "analysis.scc_path must not be empty"}, + {name: "repository root", variable: "MOONCODE_REPOSITORY_ROOT", value: " ", want: "repository.root must not be empty"}, + {name: "HTTP address", variable: "MOONCODE_HTTP_ADDRESS", value: " ", want: "http.address must not be empty"}, + {name: "secret key version", variable: "MOONCODE_SECRETS_KEY_VERSION", value: "0", want: "secrets.key_version must be greater than zero"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode") + t.Setenv("MOONCODE_AUTH_CSRF_KEY", key) + t.Setenv("MOONCODE_SECRETS_KEY", key) + t.Setenv(test.variable, test.value) + + if _, err := Load(""); err == nil || err.Error() != test.want { + t.Fatalf("Load() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestLoadAppURLFromFile(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + path := filepath.Join(t.TempDir(), "mooncode.yaml") + contents := fmt.Sprintf(` +database: + url: postgres://mooncode:test@localhost/mooncode +auth: + app_url: https://file.mooncode.example/app + csrf_key: %s +secrets: + key: %s +`, key, key) + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("MOONCODE_AUTH_APP_URL", "") + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Auth.AppURL != "https://file.mooncode.example/app" { + t.Fatalf("auth app URL = %q, want file value", cfg.Auth.AppURL) + } +} + +func TestLoadRejectsNonPositiveMirrorQuota(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode") + t.Setenv("MOONCODE_AUTH_CSRF_KEY", key) + t.Setenv("MOONCODE_SECRETS_KEY", key) + t.Setenv("MOONCODE_REPOSITORY_MAX_MIRROR_BYTES", "0") + + if _, err := Load(""); err == nil || err.Error() != "repository.max_mirror_bytes must be greater than zero" { + t.Fatalf("Load() error = %v", err) + } +} + +func TestLoadRejectsInvalidRegistrationMode(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode") + t.Setenv("MOONCODE_AUTH_CSRF_KEY", key) + t.Setenv("MOONCODE_SECRETS_KEY", key) + t.Setenv("MOONCODE_AUTH_REGISTRATION_MODE", "sometimes") + + if _, err := Load(""); err == nil || err.Error() != "auth.registration_mode must be disabled, invite_only, or public" { + t.Fatalf("Load() error = %v", err) + } +} diff --git a/internal/platform/database/database.go b/internal/platform/database/database.go new file mode 100644 index 0000000..86f269b --- /dev/null +++ b/internal/platform/database/database.go @@ -0,0 +1,27 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func Open(ctx context.Context, databaseURL string, maxConnections int32) (*pgxpool.Pool, error) { + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("parse database URL: %w", err) + } + config.MaxConns = maxConnections + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + return pool, nil +} diff --git a/internal/platform/database/migrate.go b/internal/platform/database/migrate.go new file mode 100644 index 0000000..d3d709a --- /dev/null +++ b/internal/platform/database/migrate.go @@ -0,0 +1,61 @@ +package database + +import ( + "context" + "fmt" + + "github.com/fuchencong/mooncode/migrations" + "github.com/jackc/pgx/v5/pgxpool" +) + +func Migrate(ctx context.Context, pool *pgxpool.Pool) error { + connection, err := pool.Acquire(ctx) + if err != nil { + return fmt.Errorf("acquire migration connection: %w", err) + } + defer connection.Release() + if _, err := connection.Exec(ctx, `SELECT pg_advisory_lock(681778115536235679)`); err != nil { + return fmt.Errorf("lock migrations: %w", err) + } + defer func() { _, _ = connection.Exec(context.Background(), `SELECT pg_advisory_unlock(681778115536235679)`) }() + + if _, err := connection.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { + return fmt.Errorf("create migration table: %w", err) + } + + names, err := migrationNames() + if err != nil { + return err + } + for _, name := range names { + var applied bool + if err := connection.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE name=$1)`, name).Scan(&applied); err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if applied { + continue + } + + body, err := migrations.Postgres.ReadFile("postgres/" + name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + tx, err := connection.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("apply migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (name) VALUES ($1)`, name); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("record migration %s: %w", name, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit migration %s: %w", name, err) + } + } + + return nil +} diff --git a/internal/platform/database/readiness.go b/internal/platform/database/readiness.go new file mode 100644 index 0000000..5fb5039 --- /dev/null +++ b/internal/platform/database/readiness.go @@ -0,0 +1,69 @@ +package database + +import ( + "context" + "fmt" + "io/fs" + "sort" + "strings" + + "github.com/fuchencong/mooncode/migrations" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type readinessDatabase interface { + Ping(context.Context) error + QueryRow(context.Context, string, ...any) pgx.Row +} + +type Readiness struct { + database readinessDatabase +} + +func NewReadiness(pool *pgxpool.Pool) *Readiness { + return newReadiness(pool) +} + +func newReadiness(database readinessDatabase) *Readiness { + return &Readiness{database: database} +} + +func (r *Readiness) Check(ctx context.Context) error { + if err := r.database.Ping(ctx); err != nil { + return fmt.Errorf("ping database: %w", err) + } + + names, err := migrationNames() + if err != nil { + return err + } + for _, name := range names { + var applied bool + if err := r.database.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE name=$1)`, name).Scan(&applied); err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if !applied { + return fmt.Errorf("migration %s is not applied", name) + } + } + + return nil +} + +func migrationNames() ([]string, error) { + entries, err := fs.ReadDir(migrations.Postgres, "postgres") + if err != nil { + return nil, fmt.Errorf("list migrations: %w", err) + } + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".up.sql") { + names = append(names, entry.Name()) + } + } + sort.Strings(names) + + return names, nil +} diff --git a/internal/platform/database/readiness_test.go b/internal/platform/database/readiness_test.go new file mode 100644 index 0000000..1918830 --- /dev/null +++ b/internal/platform/database/readiness_test.go @@ -0,0 +1,69 @@ +package database + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/jackc/pgx/v5" +) + +type readinessDatabaseStub struct { + pingError error + applied bool + rowError error +} + +func (s readinessDatabaseStub) Ping(context.Context) error { + return s.pingError +} + +func (s readinessDatabaseStub) QueryRow(context.Context, string, ...any) pgx.Row { + return readinessRow{applied: s.applied, err: s.rowError} +} + +type readinessRow struct { + applied bool + err error +} + +func (r readinessRow) Scan(destinations ...any) error { + if r.err != nil { + return r.err + } + *(destinations[0].(*bool)) = r.applied + + return nil +} + +func TestMigrationNamesContainOnlyOrderedUpMigrations(t *testing.T) { + names, err := migrationNames() + if err != nil { + t.Fatal(err) + } + if len(names) == 0 || names[0] != "000001_initial.up.sql" { + t.Fatalf("unexpected migration names: %v", names) + } + for index := 1; index < len(names); index++ { + if names[index-1] >= names[index] { + t.Fatalf("migration names are not strictly ordered: %v", names) + } + } +} + +func TestReadinessRequiresDatabaseAndAppliedMigrations(t *testing.T) { + databaseError := errors.New("database unavailable") + if err := newReadiness(readinessDatabaseStub{pingError: databaseError}).Check(context.Background()); !errors.Is(err, databaseError) { + t.Fatalf("ping error = %v, want wrapped database error", err) + } + + err := newReadiness(readinessDatabaseStub{applied: false}).Check(context.Background()) + if err == nil || !strings.Contains(err.Error(), "is not applied") { + t.Fatalf("missing migration error = %v", err) + } + + if err := newReadiness(readinessDatabaseStub{applied: true}).Check(context.Background()); err != nil { + t.Fatalf("ready database failed: %v", err) + } +} diff --git a/internal/platform/fault/fault.go b/internal/platform/fault/fault.go new file mode 100644 index 0000000..770303c --- /dev/null +++ b/internal/platform/fault/fault.go @@ -0,0 +1,59 @@ +package fault + +import "errors" + +type Kind uint8 + +const ( + Invalid Kind = iota + 1 + Forbidden + NotFound + Conflict + Unavailable +) + +type Error struct { + kind Kind + code string + message string + cause error +} + +func New(kind Kind, code, message string) error { + return &Error{kind: kind, code: code, message: message} +} + +func Wrap(kind Kind, code, message string, cause error) error { + return &Error{kind: kind, code: code, message: message, cause: cause} +} + +func (e *Error) Error() string { + if e.cause != nil { + return e.message + ": " + e.cause.Error() + } + + return e.message +} + +func (e *Error) Unwrap() error { + return e.cause +} + +func (e *Error) Kind() Kind { + return e.kind +} + +func (e *Error) Code() string { + return e.code +} + +func (e *Error) PublicMessage() string { + return e.message +} + +func From(err error) (*Error, bool) { + var result *Error + ok := errors.As(err, &result) + + return result, ok +} diff --git a/internal/platform/fault/fault_test.go b/internal/platform/fault/fault_test.go new file mode 100644 index 0000000..bbd04a2 --- /dev/null +++ b/internal/platform/fault/fault_test.go @@ -0,0 +1,18 @@ +package fault + +import ( + "errors" + "testing" +) + +func TestWrapPreservesCauseAndPublicMessage(t *testing.T) { + cause := errors.New("private detail") + err := Wrap(Unavailable, "provider.unavailable", "Provider is unavailable", cause) + problem, ok := From(err) + if !ok || problem.Code() != "provider.unavailable" || problem.PublicMessage() != "Provider is unavailable" { + t.Fatalf("unexpected fault: %#v, %v", problem, ok) + } + if !errors.Is(err, cause) { + t.Fatal("wrapped cause was not preserved") + } +} diff --git a/internal/platform/hatchet/client.go b/internal/platform/hatchet/client.go new file mode 100644 index 0000000..94e244f --- /dev/null +++ b/internal/platform/hatchet/client.go @@ -0,0 +1,361 @@ +package hatchet + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + analysisworkflow "github.com/fuchencong/mooncode/internal/analysis/workflow" + channelworkflow "github.com/fuchencong/mooncode/internal/channel/workflow" + repositoryworkflow "github.com/fuchencong/mooncode/internal/repository/workflow" + retentionworkflow "github.com/fuchencong/mooncode/internal/retention/workflow" + "github.com/google/uuid" + legacyclient "github.com/hatchet-dev/hatchet/pkg/client" //nolint:staticcheck // Hatchet v0.98.9's new SDK constructor still exposes these configuration options. + "github.com/hatchet-dev/hatchet/pkg/client/rest" + "github.com/hatchet-dev/hatchet/pkg/client/types" + "github.com/hatchet-dev/hatchet/pkg/worker" //nolint:staticcheck // Hatchet v0.98.9's new SDK documents this type for non-retryable task errors. + hatchetsdk "github.com/hatchet-dev/hatchet/sdks/go" +) + +type RepositoryInput struct { + RepositoryOperationID string `json:"repository_operation_id"` + RepositoryID string `json:"repository_id"` +} + +type AnalysisInput struct { + AnalysisRunID string `json:"analysis_run_id"` + RepositoryID string `json:"repository_id"` +} + +type NotificationInput struct { + NotificationID string `json:"notification_id"` + ChannelID string `json:"channel_id"` +} + +type RetentionSchedulerInput struct{} + +type RetentionInput struct { + CleanupID string `json:"cleanup_id"` + WorkspaceID string `json:"workspace_id"` +} + +type Result struct { + Completed bool `json:"completed"` +} + +const ( + taskRepositoryOperation = "repository-operation" + taskAnalysisRun = "analysis-run" + taskNotificationDelivery = "notification-delivery" + taskRetentionScheduler = "retention-scheduler" + taskRetentionCleanup = "retention-cleanup" + + repositoryConcurrencyExpression = "input.repository_id" + channelConcurrencyExpression = "input.channel_id" + workspaceConcurrencyExpression = "input.workspace_id" + + runConfirmationTimeout = 5 * time.Second + runConfirmationInterval = 100 * time.Millisecond + idempotencyTTL = 24 * time.Hour +) + +type Client struct { + hatchet *hatchetsdk.Client + repository *hatchetsdk.StandaloneTask + analysis *hatchetsdk.StandaloneTask + notification *hatchetsdk.StandaloneTask + retentionScheduler *hatchetsdk.StandaloneTask + retention *hatchetsdk.StandaloneTask + runExists func(context.Context, uuid.UUID) error +} + +type workflowRunner interface { + RunNoWait(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) +} + +func New( + token, address, namespace string, + repositoryRunner *repositoryworkflow.Runner, + analysisRunner *analysisworkflow.Runner, + notificationRunner *channelworkflow.Runner, + retentionScheduler *retentionworkflow.Scheduler, + retentionRunner *retentionworkflow.Runner, + options ...Option, +) (*Client, error) { + hatchetClient, err := newClient(token, address, namespace) + if err != nil { + return nil, err + } + + clientSettings := settings{metrics: discardTaskMetrics{}} + for _, option := range options { + option(&clientSettings) + } + + strategy := types.GroupRoundRobin + maxRuns := int32(1) + repositoryTask := hatchetClient.NewStandaloneTask( + taskRepositoryOperation, + func(ctx hatchetsdk.Context, input RepositoryInput) (*Result, error) { + return observeTask(clientSettings.metrics, taskRepositoryOperation, func() (*Result, error) { + operationID, err := uuid.Parse(input.RepositoryOperationID) + if err != nil { + return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries. + } + + err = repositoryRunner.Execute(ctx, operationID) + err = taskFailure(ctx.RetryCount(), 3, err, func(projectionContext context.Context, cause error) error { + return repositoryRunner.MarkFailed(projectionContext, operationID, cause) + }) + + return &Result{Completed: err == nil}, err + }) + }, + hatchetsdk.WithExecutionTimeout(30*time.Minute), + hatchetsdk.WithScheduleTimeout(10*time.Minute), + hatchetsdk.WithRetries(3), + hatchetsdk.WithRetryBackoff(2, 60), + hatchetsdk.WithWorkflowConcurrency(types.Concurrency{Expression: repositoryConcurrencyExpression, MaxRuns: &maxRuns, LimitStrategy: &strategy}), + workflowIdempotency("input.repository_operation_id"), + ) + analysisTask := hatchetClient.NewStandaloneTask( + taskAnalysisRun, + func(ctx hatchetsdk.Context, input AnalysisInput) (*Result, error) { + return observeTask(clientSettings.metrics, taskAnalysisRun, func() (*Result, error) { + runID, err := uuid.Parse(input.AnalysisRunID) + if err != nil { + return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries. + } + + err = analysisRunner.Execute(ctx, runID) + err = taskFailure(ctx.RetryCount(), 2, err, func(projectionContext context.Context, cause error) error { + return analysisRunner.MarkFailed(projectionContext, runID, cause) + }) + + return &Result{Completed: err == nil}, err + }) + }, + hatchetsdk.WithExecutionTimeout(20*time.Minute), + hatchetsdk.WithScheduleTimeout(10*time.Minute), + hatchetsdk.WithRetries(2), + hatchetsdk.WithRetryBackoff(2, 60), + hatchetsdk.WithWorkflowConcurrency(types.Concurrency{Expression: repositoryConcurrencyExpression, MaxRuns: &maxRuns, LimitStrategy: &strategy}), + workflowIdempotency("input.analysis_run_id"), + ) + notificationTask := hatchetClient.NewStandaloneTask( + taskNotificationDelivery, + func(ctx hatchetsdk.Context, input NotificationInput) (*Result, error) { + return observeTask(clientSettings.metrics, taskNotificationDelivery, func() (*Result, error) { + notificationID, err := uuid.Parse(input.NotificationID) + if err != nil { + return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries. + } + + err = notificationRunner.Execute(ctx, notificationID) + err = taskFailure(ctx.RetryCount(), 5, err, func(projectionContext context.Context, cause error) error { + return notificationRunner.MarkFailed(projectionContext, notificationID, cause) + }) + + return &Result{Completed: err == nil}, err + }) + }, + hatchetsdk.WithExecutionTimeout(2*time.Minute), + hatchetsdk.WithScheduleTimeout(10*time.Minute), + hatchetsdk.WithRetries(5), + hatchetsdk.WithRetryBackoff(2, 60), + hatchetsdk.WithWorkflowConcurrency(types.Concurrency{Expression: channelConcurrencyExpression, MaxRuns: &maxRuns, LimitStrategy: &strategy}), + workflowIdempotency("input.notification_id"), + ) + retentionSchedulerTask := hatchetClient.NewStandaloneTask( + taskRetentionScheduler, + func(ctx hatchetsdk.Context, _ RetentionSchedulerInput) (*Result, error) { + return observeTask(clientSettings.metrics, taskRetentionScheduler, func() (*Result, error) { + err := retentionScheduler.Execute(ctx) + + return &Result{Completed: err == nil}, err + }) + }, + hatchetsdk.WithWorkflowCron("0 * * * *"), + hatchetsdk.WithExecutionTimeout(5*time.Minute), + hatchetsdk.WithScheduleTimeout(10*time.Minute), + hatchetsdk.WithRetries(3), + hatchetsdk.WithRetryBackoff(2, 60), + ) + retentionTask := hatchetClient.NewStandaloneTask( + taskRetentionCleanup, + func(ctx hatchetsdk.Context, input RetentionInput) (*Result, error) { + return observeTask(clientSettings.metrics, taskRetentionCleanup, func() (*Result, error) { + cleanupID, err := uuid.Parse(input.CleanupID) + if err != nil { + return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries. + } + if _, err = uuid.Parse(input.WorkspaceID); err != nil { + return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries. + } + + err = retentionRunner.Execute(ctx, cleanupID) + err = taskFailure(ctx.RetryCount(), 5, err, func(projectionContext context.Context, cause error) error { + return retentionRunner.MarkFailed(projectionContext, cleanupID, cause) + }) + + return &Result{Completed: err == nil}, err + }) + }, + hatchetsdk.WithExecutionTimeout(30*time.Minute), + hatchetsdk.WithScheduleTimeout(10*time.Minute), + hatchetsdk.WithRetries(5), + hatchetsdk.WithRetryBackoff(2, 60), + hatchetsdk.WithWorkflowConcurrency(types.Concurrency{Expression: workspaceConcurrencyExpression, MaxRuns: &maxRuns, LimitStrategy: &strategy}), + workflowIdempotency("input.cleanup_id"), + ) + + return &Client{ + hatchet: hatchetClient, repository: repositoryTask, analysis: analysisTask, + notification: notificationTask, retentionScheduler: retentionSchedulerTask, retention: retentionTask, + runExists: func(ctx context.Context, id uuid.UUID) error { + _, err := hatchetClient.Runs().GetDetails(ctx, id) + + return err + }, + }, nil +} + +func newClient(token, address, namespace string) (*hatchetsdk.Client, error) { + if strings.TrimSpace(token) == "" { + return nil, errors.New("hatchet token is required") + } + + host, portText, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("parse Hatchet address: %w", err) + } + port, err := strconv.Atoi(portText) + if err != nil || port <= 0 || port > 65535 { + return nil, errors.New("hatchet address has an invalid port") + } + + clientOptions := []legacyclient.ClientOpt{ //nolint:staticcheck // Hatchet v0.98.9's new SDK constructor requires this option type. + legacyclient.WithToken(token), //nolint:staticcheck // The current SDK exposes its configuration through ClientOpt. + legacyclient.WithHostPort(host, port), //nolint:staticcheck // The current SDK exposes its configuration through ClientOpt. + } + if strings.TrimSpace(namespace) != "" { + clientOptions = append(clientOptions, legacyclient.WithNamespace(namespace)) //nolint:staticcheck // The current SDK exposes its configuration through ClientOpt. + } + + return hatchetsdk.NewClient(clientOptions...) +} + +func workflowIdempotency(expression string) hatchetsdk.WorkflowOption { + return hatchetsdk.WithWorkflowIdempotency(hatchetsdk.IdempotencyConfig{ + Expression: expression, + TTL: idempotencyTTL, + Method: hatchetsdk.IdempotencyMethodTTL, + }) +} + +func (c *Client) DispatchRepository(ctx context.Context, operationID, repositoryID uuid.UUID) (string, error) { + return c.dispatch(ctx, c.repository, RepositoryInput{RepositoryOperationID: operationID.String(), RepositoryID: repositoryID.String()}) +} + +func (c *Client) DispatchAnalysis(ctx context.Context, runID, repositoryID uuid.UUID) (string, error) { + return c.dispatch(ctx, c.analysis, AnalysisInput{AnalysisRunID: runID.String(), RepositoryID: repositoryID.String()}) +} + +func (c *Client) DispatchNotification(ctx context.Context, notificationID, channelID uuid.UUID) (string, error) { + return c.dispatch(ctx, c.notification, NotificationInput{NotificationID: notificationID.String(), ChannelID: channelID.String()}) +} + +func (c *Client) DispatchRetention(ctx context.Context, cleanupID, workspaceID uuid.UUID) (string, error) { + return c.dispatch(ctx, c.retention, RetentionInput{CleanupID: cleanupID.String(), WorkspaceID: workspaceID.String()}) +} + +func (c *Client) dispatch(ctx context.Context, task workflowRunner, input any) (string, error) { + ref, err := task.RunNoWait(ctx, input) + var runID string + if err == nil { + if ref != nil { + runID = ref.RunId + } + } else if collision, ok := hatchetsdk.IsIdempotencyCollisionError(err); ok { + runID = collision.ExistingRunExternalId + } else { + return "", err + } + if runID == "" { + return "", errors.New("hatchet returned an empty workflow run ID") + } + + if err := c.confirmRun(ctx, runID); err != nil { + return "", err + } + + return runID, nil +} + +func (c *Client) confirmRun(ctx context.Context, runID string) error { + id, err := uuid.Parse(runID) + if err != nil { + return fmt.Errorf("parse Hatchet workflow run ID: %w", err) + } + + confirmationContext, cancel := context.WithTimeout(ctx, runConfirmationTimeout) + defer cancel() + + var lastErr error + for { + if err = c.runExists(confirmationContext, id); err == nil { + return nil + } + lastErr = err + + timer := time.NewTimer(runConfirmationInterval) + select { + case <-confirmationContext.Done(): + timer.Stop() + + return fmt.Errorf("confirm Hatchet workflow run %s: %w", runID, errors.Join(lastErr, confirmationContext.Err())) + case <-timer.C: + } + } +} + +func (c *Client) CancelRepository(ctx context.Context, workflowID string) error { + return c.cancel(ctx, workflowID) +} + +func (c *Client) CancelAnalysis(ctx context.Context, workflowID string) error { + return c.cancel(ctx, workflowID) +} + +func (c *Client) CancelWorkflow(ctx context.Context, workflowID string) error { + return c.cancel(ctx, workflowID) +} + +func (c *Client) cancel(ctx context.Context, workflowID string) error { + id, err := uuid.Parse(workflowID) + if err != nil { + return err + } + + ids := []uuid.UUID{id} + _, err = c.hatchet.Runs().Cancel(ctx, rest.V1CancelTaskRequest{ExternalIds: &ids}) + + return err +} + +func (c *Client) StartWorker(ctx context.Context, name string, slots int) error { + value, err := c.hatchet.NewWorker( + name, + hatchetsdk.WithSlots(slots), + hatchetsdk.WithWorkflows(c.repository, c.analysis, c.notification, c.retentionScheduler, c.retention), + ) + if err != nil { + return err + } + + return value.StartBlocking(ctx) +} diff --git a/internal/platform/hatchet/failure.go b/internal/platform/hatchet/failure.go new file mode 100644 index 0000000..8d6d842 --- /dev/null +++ b/internal/platform/hatchet/failure.go @@ -0,0 +1,47 @@ +package hatchet + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/fuchencong/mooncode/internal/platform/fault" + workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/hatchet-dev/hatchet/pkg/worker" //nolint:staticcheck // Hatchet v0.98.9's new SDK documents this type for non-retryable task errors. + "github.com/jackc/pgx/v5" +) + +const failureProjectionTimeout = 15 * time.Second + +func taskFailure(retryCount, maxRetries int, cause error, markFailed func(context.Context, error) error) error { + if cause == nil { + return nil + } + permanent := isPermanentTaskError(cause) + if permanent || retryCount >= maxRetries { + projectionContext, cancel := context.WithTimeout(context.Background(), failureProjectionTimeout) + err := markFailed(projectionContext, cause) + cancel() + if err != nil { + return errors.Join(cause, fmt.Errorf("project workflow failure: %w", err)) + } + } + if permanent { + return worker.NewNonRetryableError(cause) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries. + } + + return cause +} + +func isPermanentTaskError(err error) bool { + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + if workflowbiz.IsPermanent(err) || errors.Is(err, pgx.ErrNoRows) { + return true + } + problem, ok := fault.From(err) + + return ok && problem.Kind() != fault.Unavailable +} diff --git a/internal/platform/hatchet/failure_test.go b/internal/platform/hatchet/failure_test.go new file mode 100644 index 0000000..8811c0e --- /dev/null +++ b/internal/platform/hatchet/failure_test.go @@ -0,0 +1,50 @@ +package hatchet + +import ( + "context" + "errors" + "testing" + + "github.com/fuchencong/mooncode/internal/platform/fault" + workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/hatchet-dev/hatchet/pkg/worker" //nolint:staticcheck // Verify the marker required by Hatchet's new SDK retry protocol. +) + +func TestTaskFailureClassificationAndProjection(t *testing.T) { + permanentCause := workflowbiz.Permanent(errors.New("invalid ref")) + marked := 0 + err := taskFailure(0, 3, permanentCause, func(ctx context.Context, _ error) error { + if _, ok := ctx.Deadline(); !ok { + t.Fatal("failure projection context has no deadline") + } + marked++ + + return nil + }) + if !worker.IsNonRetryableError(err) || marked != 1 { //nolint:staticcheck // Verify Hatchet retry classification. + t.Fatalf("permanent task failure = (%v, marked %d)", err, marked) + } + + marked = 0 + temporaryCause := fault.New(fault.Unavailable, "provider.unavailable", "Provider unavailable") + err = taskFailure(0, 3, temporaryCause, func(context.Context, error) error { marked++; return nil }) + if worker.IsNonRetryableError(err) || marked != 0 { //nolint:staticcheck // Verify Hatchet retry classification. + t.Fatalf("temporary task failure = (%v, marked %d)", err, marked) + } + err = taskFailure(3, 3, temporaryCause, func(context.Context, error) error { marked++; return nil }) + if worker.IsNonRetryableError(err) || marked != 1 { //nolint:staticcheck // Verify Hatchet retry classification. + t.Fatalf("exhausted temporary task failure = (%v, marked %d)", err, marked) + } + + if isPermanentTaskError(context.DeadlineExceeded) { + t.Fatal("context deadline was classified as permanent") + } +} + +func TestTaskFailureRetriesProjectionFailure(t *testing.T) { + projectionError := errors.New("database unavailable") + err := taskFailure(0, 3, workflowbiz.Permanent(errors.New("invalid input")), func(context.Context, error) error { return projectionError }) + if worker.IsNonRetryableError(err) || !errors.Is(err, projectionError) { //nolint:staticcheck // Verify Hatchet retry classification. + t.Fatalf("projection failure = %v, want retryable joined error", err) + } +} diff --git a/internal/platform/hatchet/options.go b/internal/platform/hatchet/options.go new file mode 100644 index 0000000..d036633 --- /dev/null +++ b/internal/platform/hatchet/options.go @@ -0,0 +1,37 @@ +package hatchet + +import "time" + +type TaskMetrics interface { + ObserveTask(task, status string, duration time.Duration) +} + +type Option func(*settings) + +type settings struct { + metrics TaskMetrics +} + +func WithMetrics(metrics TaskMetrics) Option { + return func(value *settings) { + if metrics != nil { + value.metrics = metrics + } + } +} + +type discardTaskMetrics struct{} + +func (discardTaskMetrics) ObserveTask(string, string, time.Duration) {} + +func observeTask[T any](metrics TaskMetrics, name string, task func() (*T, error)) (*T, error) { + started := time.Now() + result, err := task() + status := "succeeded" + if err != nil { + status = "failed" + } + metrics.ObserveTask(name, status, time.Since(started)) + + return result, err +} diff --git a/internal/platform/hatchet/options_test.go b/internal/platform/hatchet/options_test.go new file mode 100644 index 0000000..847bec3 --- /dev/null +++ b/internal/platform/hatchet/options_test.go @@ -0,0 +1,129 @@ +package hatchet + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/google/uuid" + hatchetsdk "github.com/hatchet-dev/hatchet/sdks/go" +) + +func TestConcurrencyExpressionsMatchSerializedInputKeys(t *testing.T) { + tests := []struct { + input any + jsonKey string + expression string + }{ + {RepositoryInput{}, "repository_id", repositoryConcurrencyExpression}, + {AnalysisInput{}, "repository_id", repositoryConcurrencyExpression}, + {NotificationInput{}, "channel_id", channelConcurrencyExpression}, + {RetentionInput{}, "workspace_id", workspaceConcurrencyExpression}, + } + for _, test := range tests { + encoded, err := json.Marshal(test.input) + if err != nil { + t.Fatal(err) + } + var object map[string]any + if err := json.Unmarshal(encoded, &object); err != nil { + t.Fatal(err) + } + if _, ok := object[test.jsonKey]; !ok { + t.Fatalf("serialized input %T has no %q key", test.input, test.jsonKey) + } + if test.expression != "input."+test.jsonKey { + t.Fatalf("expression %q does not select serialized key %q", test.expression, test.jsonKey) + } + } +} + +type taskMetrics struct { + task string + status string + duration time.Duration +} + +func (m *taskMetrics) ObserveTask(task, status string, duration time.Duration) { + m.task, m.status, m.duration = task, status, duration +} + +func TestObserveTaskRecordsFinalAttemptOutcome(t *testing.T) { + metrics := &taskMetrics{} + want := errors.New("task failed") + _, err := observeTask(metrics, taskRetentionCleanup, func() (*Result, error) { + return nil, want + }) + if !errors.Is(err, want) { + t.Fatalf("observeTask() error = %v, want %v", err, want) + } + if metrics.task != taskRetentionCleanup || metrics.status != "failed" || metrics.duration <= 0 { + t.Fatalf("task metrics = %+v", metrics) + } +} + +type workflowRunnerFunc func(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) + +func (f workflowRunnerFunc) RunNoWait(ctx context.Context, input any, options ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) { + return f(ctx, input, options...) +} + +func TestDispatchConfirmsCreatedRun(t *testing.T) { + runID := uuid.New() + confirmed := uuid.Nil + client := &Client{runExists: func(_ context.Context, id uuid.UUID) error { + confirmed = id + + return nil + }} + runner := workflowRunnerFunc(func(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) { + return &hatchetsdk.WorkflowRunRef{RunId: runID.String()}, nil + }) + + got, err := client.dispatch(context.Background(), runner, struct{}{}) + if err != nil { + t.Fatal(err) + } + if got != runID.String() || confirmed != runID { + t.Fatalf("dispatch run = %q, confirmed = %s", got, confirmed) + } +} + +func TestDispatchRecoversIdempotentRun(t *testing.T) { + runID := uuid.New() + client := &Client{runExists: func(_ context.Context, id uuid.UUID) error { + if id != runID { + t.Fatalf("confirmed run = %s, want %s", id, runID) + } + + return nil + }} + runner := workflowRunnerFunc(func(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) { + return nil, &hatchetsdk.IdempotencyCollisionError{ExistingRunExternalId: runID.String()} + }) + + got, err := client.dispatch(context.Background(), runner, struct{}{}) + if err != nil { + t.Fatal(err) + } + if got != runID.String() { + t.Fatalf("dispatch run = %q, want %q", got, runID) + } +} + +func TestDispatchRejectsUnconfirmedRun(t *testing.T) { + runID := uuid.New() + want := errors.New("run not found") + client := &Client{runExists: func(context.Context, uuid.UUID) error { return want }} + runner := workflowRunnerFunc(func(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) { + return &hatchetsdk.WorkflowRunRef{RunId: runID.String()}, nil + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + defer cancel() + if _, err := client.dispatch(ctx, runner, struct{}{}); !errors.Is(err, want) { + t.Fatalf("dispatch error = %v, want wrapped %v", err, want) + } +} diff --git a/internal/platform/httpserver/accesslog.go b/internal/platform/httpserver/accesslog.go new file mode 100644 index 0000000..0667abd --- /dev/null +++ b/internal/platform/httpserver/accesslog.go @@ -0,0 +1,30 @@ +package httpserver + +import ( + "log/slog" + "time" + + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/gin-gonic/gin" +) + +func accessLog(logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + started := time.Now() + c.Next() + + attributes := []any{ + "request_id", auth.RequestID(c), + "method", c.Request.Method, + "route", c.FullPath(), + "status", c.Writer.Status(), + "response_bytes", c.Writer.Size(), + "duration_ms", time.Since(started).Milliseconds(), + "client_ip", c.ClientIP(), + } + if actor, ok := auth.ActorFrom(c.Request.Context()); ok { + attributes = append(attributes, "user_id", actor.UserID.String()) + } + logger.InfoContext(c.Request.Context(), "http_request", attributes...) + } +} diff --git a/internal/platform/httpserver/analysis.go b/internal/platform/httpserver/analysis.go new file mode 100644 index 0000000..c7461ae --- /dev/null +++ b/internal/platform/httpserver/analysis.go @@ -0,0 +1,149 @@ +package httpserver + +import ( + "net/http" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func (s *Server) registerAnalysis(api *gin.RouterGroup) { + api.GET("/workspaces/:workspaceID/repositories/:repositoryID/analysis-runs", s.listAnalysisRuns) + api.POST("/workspaces/:workspaceID/repositories/:repositoryID/analysis-runs", s.createAnalysisRun) + api.GET("/workspaces/:workspaceID/analysis-runs/:runID", s.getAnalysisRun) + api.GET("/workspaces/:workspaceID/reports/:reportID", s.getAnalysisReport) + api.POST("/workspaces/:workspaceID/analysis-runs/:runID/cancel", s.cancelAnalysisRun) + api.POST("/workspaces/:workspaceID/analysis-runs/:runID/retry", s.retryAnalysisRun) + api.GET("/workspaces/:workspaceID/analysis-profiles", s.listAnalysisProfiles) + api.POST("/workspaces/:workspaceID/analysis-profiles", s.createAnalysisProfile) + api.POST("/workspaces/:workspaceID/analysis-profiles/:profileID/versions", s.createAnalysisProfileVersion) + api.DELETE("/workspaces/:workspaceID/analysis-profiles/:profileID", s.archiveAnalysisProfile) +} +func (s *Server) getAnalysisReport(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + reportID, ok := parameterID(c, "reportID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.analyses.Report(c, a, workspaceID, reportID) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) listAnalysisRuns(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.analyses.List(c, a, workspaceID, repositoryID, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} +func (s *Server) createAnalysisRun(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + var input struct { + SnapshotID string `json:"snapshotId"` + ProfileID string `json:"profileId"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + snapshotID := uuid.Nil + if input.SnapshotID != "" { + var err error + snapshotID, err = uuid.Parse(input.SnapshotID) + if err != nil { + failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid snapshotId")) + return + } + } + profileID, err := uuid.Parse(input.ProfileID) + if err != nil { + failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid profileId")) + return + } + item, err := s.analyses.Create(c, a, workspaceID, repositoryID, analysis.CreateInput{SnapshotID: snapshotID, ProfileID: profileID}) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusAccepted, item) +} +func (s *Server) getAnalysisRun(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + runID, ok := parameterID(c, "runID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.analyses.Get(c, a, workspaceID, runID) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) cancelAnalysisRun(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + runID, ok := parameterID(c, "runID") + if !ok { + return + } + a, _ := actor(c) + if err := s.analyses.Cancel(c, a, workspaceID, runID); err != nil { + failure(c, err) + return + } + c.Status(http.StatusNoContent) +} +func (s *Server) retryAnalysisRun(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + runID, ok := parameterID(c, "runID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.analyses.Retry(c, a, workspaceID, runID) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusAccepted, item) +} diff --git a/internal/platform/httpserver/analysis_profiles.go b/internal/platform/httpserver/analysis_profiles.go new file mode 100644 index 0000000..26b026a --- /dev/null +++ b/internal/platform/httpserver/analysis_profiles.go @@ -0,0 +1,94 @@ +package httpserver + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" +) + +func (s *Server) listAnalysisProfiles(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + page, ok := pageRequest(c) + if !ok { + return + } + actor, _ := actor(c) + profiles, err := s.analyses.Profiles(c, actor, workspaceID, page) + if err != nil { + failure(c, err) + return + } + + c.JSON(http.StatusOK, profiles) +} + +func (s *Server) createAnalysisProfile(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + var input struct { + Name string `json:"name"` + Definition json.RawMessage `json:"definition"` + } + if !bind(c, &input) { + return + } + actor, _ := actor(c) + profile, err := s.analyses.CreateProfile(c, actor, workspaceID, input.Name, input.Definition) + if err != nil { + failure(c, err) + return + } + + c.JSON(http.StatusCreated, profile) +} + +func (s *Server) createAnalysisProfileVersion(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + profileID, ok := parameterID(c, "profileID") + if !ok { + return + } + var input struct { + CurrentVersion int32 `json:"currentVersion"` + Name string `json:"name"` + Definition json.RawMessage `json:"definition"` + } + if !bind(c, &input) { + return + } + actor, _ := actor(c) + profile, err := s.analyses.UpdateProfile(c, actor, workspaceID, profileID, input.CurrentVersion, input.Name, input.Definition) + if err != nil { + failure(c, err) + return + } + + c.JSON(http.StatusCreated, profile) +} + +func (s *Server) archiveAnalysisProfile(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + profileID, ok := parameterID(c, "profileID") + if !ok { + return + } + actor, _ := actor(c) + if err := s.analyses.ArchiveProfile(c, actor, workspaceID, profileID); err != nil { + failure(c, err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/platform/httpserver/channels.go b/internal/platform/httpserver/channels.go new file mode 100644 index 0000000..6d0a25b --- /dev/null +++ b/internal/platform/httpserver/channels.go @@ -0,0 +1,257 @@ +package httpserver + +import ( + "encoding/json" + "net/http" + + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func (s *Server) registerChannels(api *gin.RouterGroup) { + group := api.Group("/workspaces/:workspaceID") + group.GET("/overview", s.workspaceOverview) + group.GET("/channels", s.listChannels) + group.POST("/channels", s.createChannel) + group.PATCH("/channels/:channelID", s.updateChannel) + group.PUT("/channels/:channelID/credentials", s.rotateChannelCredential) + group.DELETE("/channels/:channelID", s.deleteChannel) + group.POST("/channels/:channelID/enable", s.enableChannel) + group.POST("/channels/:channelID/disable", s.disableChannel) + group.GET("/channels/:channelID/status", s.channelStatus) + group.GET("/conversations", s.listConversations) + group.GET("/messages", s.listMessages) + if s.commands != nil { + api.POST("/channel-identity-links/accept", s.acceptChannelIdentityLink) + } +} + +func (s *Server) acceptChannelIdentityLink(c *gin.Context) { + var input struct { + Token string `json:"token"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + if err := s.commands.Accept(c, a, input.Token); err != nil { + failure(c, err) + return + } + c.Status(http.StatusNoContent) +} +func (s *Server) workspaceOverview(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.channels.Overview(c, a, workspaceID) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) listChannels(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.channels.List(c, a, workspaceID, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} +func (s *Server) createChannel(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + var input struct { + Type string `json:"type"` + Name string `json:"name"` + Values map[string]any `json:"values"` + Secrets map[string]string `json:"secrets"` + SenderAllowList []string `json:"senderAllowList"` + GroupPolicy map[string]any `json:"groupPolicy"` + NotificationEvents []string `json:"notificationEvents"` + } + if !bind(c, &input) { + return + } + config, _ := json.Marshal(gin.H{"values": input.Values, "senderAllowList": input.SenderAllowList, "groupPolicy": input.GroupPolicy}) + credentials, _ := json.Marshal(input.Secrets) + a, _ := actor(c) + item, err := s.channels.Create(c, a, workspaceID, input.Type, input.Name, string(credentials), config, input.NotificationEvents) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusCreated, item) +} +func (s *Server) updateChannel(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + channelID, ok := parameterID(c, "channelID") + if !ok { + return + } + var input struct { + Version int64 `json:"version"` + Name string `json:"name"` + Values map[string]any `json:"values"` + SenderAllowList []string `json:"senderAllowList"` + GroupPolicy map[string]any `json:"groupPolicy"` + NotificationEvents []string `json:"notificationEvents"` + } + if !bind(c, &input) { + return + } + config, _ := json.Marshal(gin.H{"values": input.Values, "senderAllowList": input.SenderAllowList, "groupPolicy": input.GroupPolicy}) + a, _ := actor(c) + item, err := s.channels.Update(c, a, workspaceID, channelID, input.Name, config, input.NotificationEvents, input.Version) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) rotateChannelCredential(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + channelID, ok := parameterID(c, "channelID") + if !ok { + return + } + var input struct { + Version int64 `json:"version"` + Secrets map[string]string `json:"secrets"` + } + if !bind(c, &input) { + return + } + credentials, _ := json.Marshal(input.Secrets) + a, _ := actor(c) + item, err := s.channels.RotateCredential(c, a, workspaceID, channelID, string(credentials), input.Version) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) deleteChannel(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + channelID, ok := parameterID(c, "channelID") + if !ok { + return + } + a, _ := actor(c) + if err := s.channels.Delete(c, a, workspaceID, channelID); err != nil { + failure(c, err) + return + } + c.Status(http.StatusNoContent) +} +func (s *Server) enableChannel(c *gin.Context) { s.setChannelEnabled(c, true) } +func (s *Server) disableChannel(c *gin.Context) { s.setChannelEnabled(c, false) } +func (s *Server) setChannelEnabled(c *gin.Context, enabled bool) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + channelID, ok := parameterID(c, "channelID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.channels.Enable(c, a, workspaceID, channelID, enabled) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) channelStatus(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + channelID, ok := parameterID(c, "channelID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.channels.Status(c, a, workspaceID, channelID) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) listConversations(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.channels.Conversations(c, a, workspaceID, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} +func (s *Server) listMessages(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + channelID, conversationID := uuid.NullUUID{}, uuid.NullUUID{} + if value := c.Query("channelId"); value != "" { + parsed, err := uuid.Parse(value) + if err != nil { + failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid channelId")) + return + } + channelID = uuid.NullUUID{UUID: parsed, Valid: true} + } + if value := c.Query("conversationId"); value != "" { + parsed, err := uuid.Parse(value) + if err != nil { + failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid conversationId")) + return + } + conversationID = uuid.NullUUID{UUID: parsed, Valid: true} + } + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.channels.Messages(c, a, workspaceID, channelID, conversationID, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} diff --git a/internal/platform/httpserver/channels_test.go b/internal/platform/httpserver/channels_test.go new file mode 100644 index 0000000..c8d9540 --- /dev/null +++ b/internal/platform/httpserver/channels_test.go @@ -0,0 +1,19 @@ +package httpserver + +import ( + "testing" + + "github.com/gin-gonic/gin" +) + +func TestChannelIdentityLinkRouteIsNotRegisteredWithoutCommandService(t *testing.T) { + router := gin.New() + server := &Server{} + server.registerChannels(router.Group("/api/v1")) + + for _, route := range router.Routes() { + if route.Path == "/api/v1/channel-identity-links/accept" { + t.Fatal("channel identity link route was registered without a command service") + } + } +} diff --git a/internal/platform/httpserver/helpers.go b/internal/platform/httpserver/helpers.go new file mode 100644 index 0000000..f566a69 --- /dev/null +++ b/internal/platform/httpserver/helpers.go @@ -0,0 +1,85 @@ +package httpserver + +import ( + "context" + "errors" + "net/http" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +func actor(c *gin.Context) (auth.Actor, bool) { return auth.ActorFrom(c.Request.Context()) } +func parameterID(c *gin.Context, name string) (uuid.UUID, bool) { + value, err := uuid.Parse(c.Param(name)) + if err != nil { + failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid "+name)) + return uuid.Nil, false + } + return value, true +} +func bind(c *gin.Context, value any) bool { + if err := c.ShouldBindJSON(value); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + failure(c, fault.New(fault.Invalid, "request.body_too_large", "Request body exceeds the allowed size")) + return false + } + failure(c, fault.New(fault.Invalid, "request.body_invalid", "Request body must be valid JSON")) + return false + } + return true +} + +func pageRequest(c *gin.Context) (pagination.Request, bool) { + request, err := pagination.Parse(c.Query("cursor"), c.Query("limit")) + if err != nil { + failure(c, fault.Wrap(fault.Invalid, "request.page_invalid", "Page cursor or limit is invalid", err)) + + return pagination.Request{}, false + } + + return request, true +} + +func failure(c *gin.Context, err error) { + status, code, message := http.StatusInternalServerError, "internal.error", "MoonCode could not complete the request" + problem, known := fault.From(err) + if known { + code, message = problem.Code(), problem.PublicMessage() + switch problem.Kind() { + case fault.Invalid: + status = http.StatusBadRequest + if code == "request.body_too_large" { + status = http.StatusRequestEntityTooLarge + } + case fault.Forbidden: + status = http.StatusForbidden + case fault.NotFound: + status = http.StatusNotFound + case fault.Conflict: + status = http.StatusConflict + case fault.Unavailable: + status = http.StatusServiceUnavailable + } + } else { + var postgres *pgconn.PgError + switch { + case errors.Is(err, context.DeadlineExceeded): + status, code, message = http.StatusGatewayTimeout, "request.timeout", "Request timed out" + case errors.Is(err, identity.ErrForbidden): + status, code, message = http.StatusForbidden, "workspace.forbidden", "Workspace permission denied" + case errors.Is(err, pgx.ErrNoRows): + status, code, message = http.StatusNotFound, "resource.not_found", "Resource not found" + case errors.As(err, &postgres) && postgres.Code == "23505": + status, code, message = http.StatusConflict, "resource.conflict", "Resource already exists" + } + } + c.JSON(status, gin.H{"error": gin.H{"code": code, "message": message, "requestId": auth.RequestID(c)}}) +} diff --git a/internal/platform/httpserver/identity.go b/internal/platform/httpserver/identity.go new file mode 100644 index 0000000..8b0a45c --- /dev/null +++ b/internal/platform/httpserver/identity.go @@ -0,0 +1,213 @@ +package httpserver + +import ( + "net/http" + + biz "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func (s *Server) registerIdentity(api, active *gin.RouterGroup) { + api.GET("/csrf", s.issueCSRF) + api.GET("/session", s.session) + api.POST("/onboarding/complete", s.completeOnboarding) + api.POST("/invitations/accept", s.acceptInvitation) + active.POST("/workspaces", s.createWorkspace) + active.PATCH("/workspaces/:workspaceID", s.updateWorkspace) + active.GET("/workspaces/:workspaceID/members", s.listMembers) + active.DELETE("/workspaces/:workspaceID/members/:userID", s.removeMember) + active.GET("/workspaces/:workspaceID/invitations", s.listInvitations) + active.POST("/workspaces/:workspaceID/invitations", s.createInvitation) + active.DELETE("/workspaces/:workspaceID/invitations/:invitationID", s.revokeInvitation) +} +func (s *Server) issueCSRF(c *gin.Context) { + a, _ := actor(c) + token, err := s.csrf.Issue(a) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"token": token}) +} +func (s *Server) session(c *gin.Context) { + a, _ := actor(c) + user, workspaces, err := s.identity.Session(c, a) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"user": user, "workspaces": workspaces, "auth": gin.H{"logoutUrl": s.config.LogoutURL}, "registration": gin.H{"mode": s.config.RegistrationMode, "termsVersion": s.config.TermsVersion, "privacyVersion": s.config.PrivacyVersion}}) +} +func (s *Server) completeOnboarding(c *gin.Context) { + var input struct { + AcceptTerms bool `json:"acceptTerms"` + AcceptPrivacy bool `json:"acceptPrivacy"` + } + if !bind(c, &input) { + return + } + if !input.AcceptTerms || !input.AcceptPrivacy { + failure(c, fault.New(fault.Invalid, "account.terms_required", "Terms and privacy policy must be accepted")) + return + } + a, _ := actor(c) + if s.config.RegistrationMode != "public" { + failure(c, fault.New(fault.Forbidden, "account.registration_closed", "Registration is not public")) + return + } + if err := s.identity.CompleteOnboarding(c, a); err != nil { + failure(c, err) + return + } + s.session(c) +} +func (s *Server) acceptInvitation(c *gin.Context) { + var input struct { + Token string `json:"token"` + AcceptTerms bool `json:"acceptTerms"` + AcceptPrivacy bool `json:"acceptPrivacy"` + } + if !bind(c, &input) { + return + } + if !input.AcceptTerms || !input.AcceptPrivacy { + failure(c, fault.New(fault.Invalid, "account.terms_required", "Terms and privacy policy must be accepted")) + return + } + a, _ := actor(c) + if s.config.RegistrationMode == "disabled" && a.Status != "active" { + failure(c, fault.New(fault.Forbidden, "account.registration_closed", "Registration is disabled")) + return + } + if err := s.identity.AcceptInvitation(c, a, input.Token); err != nil { + failure(c, err) + return + } + s.session(c) +} +func (s *Server) createWorkspace(c *gin.Context) { + var input struct { + Name string `json:"name"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + item, err := s.identity.CreateWorkspace(c, a, input.Name) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusCreated, item) +} +func (s *Server) updateWorkspace(c *gin.Context) { + id, ok := parameterID(c, "workspaceID") + if !ok { + return + } + var input struct { + Name *string `json:"name"` + ReportRetentionDays *int32 `json:"reportRetentionDays"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + item, err := s.identity.UpdateWorkspace(c, a, id, biz.WorkspaceUpdate{ + Name: input.Name, ReportRetentionDays: input.ReportRetentionDays, + }) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) listMembers(c *gin.Context) { + id, ok := parameterID(c, "workspaceID") + if !ok { + return + } + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.identity.Members(c, a, id, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} +func (s *Server) removeMember(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + userID, ok := parameterID(c, "userID") + if !ok { + return + } + a, _ := actor(c) + if err := s.identity.RemoveMember(c, a, workspaceID, userID); err != nil { + failure(c, err) + return + } + c.Status(http.StatusNoContent) +} +func (s *Server) listInvitations(c *gin.Context) { + id, ok := parameterID(c, "workspaceID") + if !ok { + return + } + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.identity.Invitations(c, a, id, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} +func (s *Server) createInvitation(c *gin.Context) { + id, ok := parameterID(c, "workspaceID") + if !ok { + return + } + var input struct { + Email string `json:"email"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + item, err := s.identity.Invite(c, a, id, input.Email) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusCreated, item) +} +func (s *Server) revokeInvitation(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + invitationID, ok := parameterID(c, "invitationID") + if !ok { + return + } + a, _ := actor(c) + if err := s.identity.RevokeInvitation(c, a, workspaceID, invitationID); err != nil { + failure(c, err) + return + } + c.Status(http.StatusNoContent) +} + +var _ = uuid.Nil diff --git a/internal/platform/httpserver/identity_rbac_test.go b/internal/platform/httpserver/identity_rbac_test.go new file mode 100644 index 0000000..1eae16c --- /dev/null +++ b/internal/platform/httpserver/identity_rbac_test.go @@ -0,0 +1,326 @@ +package httpserver + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/google/uuid" +) + +type identityStore struct { + user identity.User + workspace identity.Workspace + role string + invitationMade bool + invitationAccepted bool + memberRemoved bool + workspaceCreated bool + removeResult bool + workspaceUpdate identity.WorkspaceUpdate +} + +func (s *identityStore) ResolveIdentity(context.Context, string, auth.Identity) (identity.User, error) { + return s.user, nil +} +func (s *identityStore) ActivateUser(context.Context, uuid.UUID) (identity.User, error) { + return s.user, nil +} +func (s *identityStore) GetUser(context.Context, uuid.UUID) (identity.User, error) { + return s.user, nil +} +func (s *identityStore) ListWorkspaces(context.Context, uuid.UUID) ([]identity.Workspace, error) { + return []identity.Workspace{s.workspace}, nil +} +func (s *identityStore) CreateWorkspace(context.Context, uuid.UUID, string, string) (identity.Workspace, error) { + s.workspaceCreated = true + return s.workspace, nil +} +func (s *identityStore) GetMembership(context.Context, uuid.UUID, uuid.UUID) (identity.Membership, error) { + return identity.Membership{Workspace: s.workspace, Role: s.role}, nil +} +func (s *identityStore) UpdateWorkspace(_ context.Context, _ uuid.UUID, update identity.WorkspaceUpdate) (identity.Workspace, error) { + s.workspaceUpdate = update + return s.workspace, nil +} +func (s *identityStore) ListMembers(context.Context, uuid.UUID, pagination.Request) (pagination.Page[identity.Member], error) { + return pagination.Page[identity.Member]{Items: []identity.Member{{UserID: s.user.ID, Role: s.role}}}, nil +} +func (s *identityStore) RemoveMember(context.Context, uuid.UUID, uuid.UUID) (bool, error) { + s.memberRemoved = true + + return s.removeResult, nil +} +func (s *identityStore) CreateInvitation(_ context.Context, workspaceID, _ uuid.UUID, email string, _ []byte, expiresAt time.Time) (identity.Invitation, error) { + s.invitationMade = true + return identity.Invitation{ID: uuid.New(), WorkspaceID: workspaceID, Email: email, ExpiresAt: expiresAt}, nil +} +func (s *identityStore) ListInvitations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[identity.Invitation], error) { + return pagination.Page[identity.Invitation]{}, nil +} +func (s *identityStore) RevokeInvitation(context.Context, uuid.UUID, uuid.UUID) (bool, error) { + return true, nil +} +func (s *identityStore) AcceptInvitation(context.Context, uuid.UUID, []byte, string) (identity.Workspace, error) { + s.invitationAccepted = true + return s.workspace, nil +} + +func TestPendingAccountCanOnboardButCannotUseSaaSResources(t *testing.T) { + store := &identityStore{user: identity.User{ID: uuid.New(), Status: "pending"}} + config := testHTTPConfig() + config.RegistrationMode = "public" + server, err := New(config, identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901"))) + if err != nil { + t.Fatal(err) + } + + sessionRequest := httptest.NewRequest(http.MethodGet, "/api/v1/session", nil) + sessionRequest.Header.Set("Remote-Sub", "github-user") + sessionResponse := httptest.NewRecorder() + server.Handler().ServeHTTP(sessionResponse, sessionRequest) + if sessionResponse.Code != http.StatusOK { + t.Fatalf("pending session = %d %s", sessionResponse.Code, sessionResponse.Body.String()) + } + + token := issueToken(t, server.Handler()) + request := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces", bytes.NewBufferString(`{"name":"Bypass"}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Remote-Sub", "github-user") + request.Header.Set("X-CSRF-Token", token) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), `"code":"account.onboarding_required"`) { + t.Fatalf("pending resource access = %d %s", response.Code, response.Body.String()) + } + if store.workspaceCreated { + t.Fatal("pending account created a workspace") + } +} + +func TestDisabledRegistrationRejectsInvitationAcceptance(t *testing.T) { + store := &identityStore{user: identity.User{ID: uuid.New(), Status: "pending"}} + config := testHTTPConfig() + config.RegistrationMode = "disabled" + server, err := New(config, identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901"))) + if err != nil { + t.Fatal(err) + } + token := issueToken(t, server.Handler()) + request := httptest.NewRequest(http.MethodPost, "/api/v1/invitations/accept", bytes.NewBufferString(`{"token":"invitation","acceptTerms":true,"acceptPrivacy":true}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Remote-Sub", "github-user") + request.Header.Set("X-CSRF-Token", token) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), `"code":"account.registration_closed"`) { + t.Fatalf("disabled invitation acceptance = %d %s", response.Code, response.Body.String()) + } + if store.invitationAccepted { + t.Fatal("disabled registration accepted an invitation") + } +} + +func TestWorkspaceInvitationRequiresAdminRole(t *testing.T) { + for _, test := range []struct { + name string + role string + wantStatus int + wantCode string + }{ + {name: "member forbidden", role: "member", wantStatus: http.StatusForbidden, wantCode: "workspace.forbidden"}, + {name: "admin allowed", role: "admin", wantStatus: http.StatusCreated}, + } { + t.Run(test.name, func(t *testing.T) { + workspaceID := uuid.New() + store := &identityStore{ + user: identity.User{ID: uuid.New(), Username: "octocat", Email: "octocat@example.com", Status: "active"}, + workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode"}, + role: test.role, + } + identityService := identity.NewService(store, "tinyauth") + csrf := auth.NewCSRF([]byte("01234567890123456789012345678901")) + server, err := New(testHTTPConfig(), identityService, nil, nil, nil, nil, nil, csrf) + if err != nil { + t.Fatal(err) + } + token := issueToken(t, server.Handler()) + + request := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID.String()+"/invitations", bytes.NewBufferString(`{"email":"new@example.com"}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Remote-Sub", "github-user") + request.Header.Set("X-CSRF-Token", token) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + + if response.Code != test.wantStatus { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if test.wantCode != "" && !strings.Contains(response.Body.String(), `"code":"`+test.wantCode+`"`) { + t.Fatalf("body %s does not contain error code %q", response.Body.String(), test.wantCode) + } + if store.invitationMade != (test.wantStatus == http.StatusCreated) { + t.Fatalf("invitationMade = %v", store.invitationMade) + } + }) + } +} + +func TestWorkspaceMembersAllowMemberRead(t *testing.T) { + workspaceID := uuid.New() + store := &identityStore{ + user: identity.User{ID: uuid.New(), Username: "octocat", Status: "active"}, + workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode"}, + role: "member", + } + server, err := New(testHTTPConfig(), identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901"))) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/"+workspaceID.String()+"/members", nil) + request.Header.Set("Remote-Sub", "github-user") + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), store.user.ID.String()) { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } +} + +func TestWorkspaceSettingsUpdateRetentionPolicy(t *testing.T) { + workspaceID := uuid.New() + store := &identityStore{ + user: identity.User{ID: uuid.New(), Username: "octocat", Status: "active"}, + workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode", ReportRetentionDays: 90}, + role: "admin", + } + server, err := New(testHTTPConfig(), identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901"))) + if err != nil { + t.Fatal(err) + } + token := issueToken(t, server.Handler()) + request := httptest.NewRequest(http.MethodPatch, "/api/v1/workspaces/"+workspaceID.String(), bytes.NewBufferString(`{"reportRetentionDays":180}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Remote-Sub", "github-user") + request.Header.Set("X-CSRF-Token", token) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if store.workspaceUpdate.ReportRetentionDays == nil || *store.workspaceUpdate.ReportRetentionDays != 180 || store.workspaceUpdate.Name != nil { + t.Fatalf("workspace update = %+v", store.workspaceUpdate) + } +} + +func TestWorkspaceSettingsRejectInvalidRetentionPolicy(t *testing.T) { + workspaceID := uuid.New() + store := &identityStore{ + user: identity.User{ID: uuid.New(), Username: "octocat", Status: "active"}, + workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode", ReportRetentionDays: 90}, role: "admin", + } + server, err := New(testHTTPConfig(), identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901"))) + if err != nil { + t.Fatal(err) + } + token := issueToken(t, server.Handler()) + request := httptest.NewRequest(http.MethodPatch, "/api/v1/workspaces/"+workspaceID.String(), bytes.NewBufferString(`{"reportRetentionDays":0}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Remote-Sub", "github-user") + request.Header.Set("X-CSRF-Token", token) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"code":"workspace.report_retention_invalid"`) { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } +} + +func TestIdentityHeadersFromUntrustedPeerAreRejected(t *testing.T) { + store := &identityStore{user: identity.User{ID: uuid.New(), Status: "active"}} + config := Config{TrustedProxyCIDRs: []string{"127.0.0.1/32"}} + server, err := New(config, identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901"))) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/session", nil) + request.Header.Set("Remote-Sub", "forged-user") + request.RemoteAddr = "172.30.0.3:54321" + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized || !strings.Contains(response.Body.String(), `"code":"identity.untrusted_gateway"`) { + t.Fatalf("untrusted gateway response = %d %s", response.Code, response.Body.String()) + } +} + +func TestWorkspaceMemberRemovalRequiresAdminAndProtectsOwner(t *testing.T) { + for _, test := range []struct { + name string + role string + removeResult bool + wantStatus int + wantRemoved bool + }{ + {name: "member forbidden", role: "member", removeResult: true, wantStatus: http.StatusForbidden}, + {name: "admin removes member", role: "admin", removeResult: true, wantStatus: http.StatusNoContent, wantRemoved: true}, + {name: "owner is protected", role: "admin", removeResult: false, wantStatus: http.StatusConflict, wantRemoved: true}, + } { + t.Run(test.name, func(t *testing.T) { + workspaceID := uuid.New() + store := &identityStore{ + user: identity.User{ID: uuid.New(), Username: "octocat", Status: "active"}, + workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode"}, + role: test.role, removeResult: test.removeResult, + } + server, err := New(testHTTPConfig(), identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901"))) + if err != nil { + t.Fatal(err) + } + token := issueToken(t, server.Handler()) + request := httptest.NewRequest(http.MethodDelete, "/api/v1/workspaces/"+workspaceID.String()+"/members/"+uuid.NewString(), nil) + request.Header.Set("Remote-Sub", "github-user") + request.Header.Set("X-CSRF-Token", token) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + + if response.Code != test.wantStatus { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if store.memberRemoved != test.wantRemoved { + t.Fatalf("memberRemoved = %v, want %v", store.memberRemoved, test.wantRemoved) + } + if !test.removeResult && test.wantRemoved && !strings.Contains(response.Body.String(), "Member cannot be removed") { + t.Fatalf("owner protection response is not explicit: %s", response.Body.String()) + } + }) + } +} + +func issueToken(t *testing.T, handler http.Handler) string { + t.Helper() + request := httptest.NewRequest(http.MethodGet, "/api/v1/csrf", nil) + request.Header.Set("Remote-Sub", "github-user") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("issue CSRF: status = %d, body = %s", response.Code, response.Body.String()) + } + var body struct { + Token string `json:"token"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body.Token +} diff --git a/internal/platform/httpserver/metrics.go b/internal/platform/httpserver/metrics.go new file mode 100644 index 0000000..ed4028b --- /dev/null +++ b/internal/platform/httpserver/metrics.go @@ -0,0 +1,114 @@ +package httpserver + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +var durationBuckets = [...]float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10} + +type RequestMetrics interface { + Observe(method, route string, status int, duration time.Duration) + Handler() http.Handler +} + +type requestMetric struct { + count uint64 + sum float64 + buckets [len(durationBuckets)]uint64 +} + +type Metrics struct { + mu sync.Mutex + requests map[string]*requestMetric + tasks map[string]*requestMetric + retention retentionMetrics +} + +func NewMetrics() *Metrics { + return &Metrics{requests: make(map[string]*requestMetric), tasks: make(map[string]*requestMetric)} +} + +func (m *Metrics) Observe(method, route string, status int, duration time.Duration) { + if route == "" { + route = "unmatched" + } + key := method + "\x00" + route + "\x00" + strconv.Itoa(status) + seconds := duration.Seconds() + + m.mu.Lock() + metric := m.requests[key] + if metric == nil { + metric = &requestMetric{} + m.requests[key] = metric + } + metric.count++ + metric.sum += seconds + for index, upperBound := range durationBuckets { + if seconds <= upperBound { + metric.buckets[index]++ + } + } + m.mu.Unlock() +} + +func (m *Metrics) Handler() http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + writer.Header().Set("Cache-Control", "no-store") + + m.mu.Lock() + keys := make([]string, 0, len(m.requests)) + snapshot := make(map[string]requestMetric, len(m.requests)) + for key, metric := range m.requests { + keys = append(keys, key) + snapshot[key] = *metric + } + taskKeys := make([]string, 0, len(m.tasks)) + taskSnapshot := make(map[string]requestMetric, len(m.tasks)) + for key, metric := range m.tasks { + taskKeys = append(taskKeys, key) + taskSnapshot[key] = *metric + } + retention := m.retention + m.mu.Unlock() + sort.Strings(keys) + sort.Strings(taskKeys) + + _, _ = fmt.Fprintln(writer, "# HELP mooncode_http_requests_total Total HTTP requests handled by MoonCode.") + _, _ = fmt.Fprintln(writer, "# TYPE mooncode_http_requests_total counter") + _, _ = fmt.Fprintln(writer, "# HELP mooncode_http_request_duration_seconds HTTP request duration in seconds.") + _, _ = fmt.Fprintln(writer, "# TYPE mooncode_http_request_duration_seconds histogram") + for _, key := range keys { + parts := strings.Split(key, "\x00") + labels := fmt.Sprintf(`method=%q,route=%q,status=%q`, parts[0], parts[1], parts[2]) + metric := snapshot[key] + _, _ = fmt.Fprintf(writer, "mooncode_http_requests_total{%s} %d\n", labels, metric.count) + for index, upperBound := range durationBuckets { + _, _ = fmt.Fprintf(writer, "mooncode_http_request_duration_seconds_bucket{%s,le=%q} %d\n", labels, strconv.FormatFloat(upperBound, 'f', -1, 64), metric.buckets[index]) + } + _, _ = fmt.Fprintf(writer, "mooncode_http_request_duration_seconds_bucket{%s,le=\"+Inf\"} %d\n", labels, metric.count) + _, _ = fmt.Fprintf(writer, "mooncode_http_request_duration_seconds_sum{%s} %g\n", labels, metric.sum) + _, _ = fmt.Fprintf(writer, "mooncode_http_request_duration_seconds_count{%s} %d\n", labels, metric.count) + } + + writeTaskMetrics(writer, taskKeys, taskSnapshot) + writeRetentionMetrics(writer, retention) + }) +} + +func observeRequests(metrics RequestMetrics) gin.HandlerFunc { + return func(c *gin.Context) { + started := time.Now() + c.Next() + + metrics.Observe(c.Request.Method, c.FullPath(), c.Writer.Status(), time.Since(started)) + } +} diff --git a/internal/platform/httpserver/middleware.go b/internal/platform/httpserver/middleware.go new file mode 100644 index 0000000..39b8223 --- /dev/null +++ b/internal/platform/httpserver/middleware.go @@ -0,0 +1,60 @@ +package httpserver + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/fuchencong/mooncode/internal/platform/auth" +) + +func requireActiveAccount() gin.HandlerFunc { + return func(c *gin.Context) { + actor, ok := auth.ActorFrom(c.Request.Context()) + if !ok || actor.Status != "active" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "code": "account.onboarding_required", + "message": "Complete account registration first", + "requestId": auth.RequestID(c), + }, + }) + + return + } + + c.Next() + } +} + +func securityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("Referrer-Policy", "no-referrer") + c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + c.Next() + } +} + +func limitBody(maxBytes int64) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Body != nil { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) + } + c.Next() + } +} + +func requestTimeout(timeout time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) + defer cancel() + + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} diff --git a/internal/platform/httpserver/observability_test.go b/internal/platform/httpserver/observability_test.go new file mode 100644 index 0000000..9547c34 --- /dev/null +++ b/internal/platform/httpserver/observability_test.go @@ -0,0 +1,79 @@ +package httpserver + +import ( + "bytes" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/gin-gonic/gin" +) + +func TestMetricsUsesRouteTemplatesAndBoundedLabels(t *testing.T) { + metrics := NewMetrics() + metrics.Observe(http.MethodGet, "/api/v1/workspaces/:workspaceID/repositories", http.StatusOK, 25*time.Millisecond) + metrics.ObserveTask("retention-cleanup", "succeeded", 50*time.Millisecond) + metrics.AddRetentionProgress(3, 2, 1) + + request := httptest.NewRequest(http.MethodGet, "/metrics", nil) + response := httptest.NewRecorder() + metrics.Handler().ServeHTTP(response, request) + body := response.Body.String() + if response.Code != http.StatusOK || !strings.Contains(body, `mooncode_http_requests_total{method="GET",route="/api/v1/workspaces/:workspaceID/repositories",status="200"} 1`) { + t.Fatalf("unexpected metrics response: %d %s", response.Code, body) + } + if !strings.Contains(body, `mooncode_http_request_duration_seconds_count{method="GET",route="/api/v1/workspaces/:workspaceID/repositories",status="200"} 1`) { + t.Fatalf("duration histogram is missing: %s", body) + } + if !strings.Contains(body, `mooncode_hatchet_task_runs_total{task="retention-cleanup",status="succeeded"} 1`) { + t.Fatalf("Hatchet task metrics are missing: %s", body) + } + for _, metric := range []string{ + "mooncode_retention_deleted_runs_total 3", + "mooncode_retention_purged_snapshots_total 2", + "mooncode_retention_requeued_repositories_total 1", + } { + if !strings.Contains(body, metric) { + t.Fatalf("retention metric %q is missing: %s", metric, body) + } + } +} + +func TestTaskMetricsBoundsCallerProvidedLabels(t *testing.T) { + metrics := NewMetrics() + metrics.ObserveTask("workspace-"+strings.Repeat("x", 100), "custom-status", time.Millisecond) + + request := httptest.NewRequest(http.MethodGet, "/metrics", nil) + response := httptest.NewRecorder() + metrics.Handler().ServeHTTP(response, request) + body := response.Body.String() + if !strings.Contains(body, `task="unknown",status="unknown"`) || strings.Contains(body, strings.Repeat("x", 100)) { + t.Fatalf("task metrics accepted unbounded labels: %s", body) + } +} + +func TestAccessLogDoesNotRecordQueryValues(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&output, nil)) + router := gin.New() + if err := router.SetTrustedProxies(nil); err != nil { + t.Fatal(err) + } + router.Use(auth.RequestIDMiddleware(), accessLog(logger)) + router.GET("/resource/:id", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + request := httptest.NewRequest(http.MethodGet, "/resource/123?token=sensitive-value", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + logged := output.String() + if !strings.Contains(logged, `"route":"/resource/:id"`) || !strings.Contains(logged, `"status":204`) { + t.Fatalf("unexpected access log: %s", logged) + } + if strings.Contains(logged, "sensitive-value") || strings.Contains(logged, `"token"`) { + t.Fatalf("access log leaked query values: %s", logged) + } +} diff --git a/internal/platform/httpserver/openapi_routes_test.go b/internal/platform/httpserver/openapi_routes_test.go new file mode 100644 index 0000000..29a9992 --- /dev/null +++ b/internal/platform/httpserver/openapi_routes_test.go @@ -0,0 +1,84 @@ +package httpserver + +import ( + "fmt" + "regexp" + "sort" + "testing" + + openapidoc "github.com/fuchencong/mooncode/api/openapi" + channelcommand "github.com/fuchencong/mooncode/internal/channel/command" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/getkin/kin-openapi/openapi3" + "github.com/gin-gonic/gin" +) + +var ginParameterPattern = regexp.MustCompile(`:([A-Za-z0-9_]+)`) + +func TestRoutesMatchOpenAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + server, err := New( + Config{TrustedProxyCIDRs: []string{"127.0.0.1/32"}}, + nil, + nil, + nil, + nil, + nil, + &channelcommand.Service{}, + auth.NewCSRF([]byte("openapi-route-test-key")), + ) + if err != nil { + t.Fatal(err) + } + + document, err := openapidoc.Document() + if err != nil { + t.Fatal(err) + } + specification, err := openapi3.NewLoader().LoadFromData(document) + if err != nil { + t.Fatal(err) + } + + implemented := make(map[string]struct{}) + for _, route := range server.router.Routes() { + path := ginParameterPattern.ReplaceAllString(route.Path, `{$1}`) + implemented[route.Method+" "+path] = struct{}{} + } + + documented := make(map[string]struct{}) + for path, item := range specification.Paths.Map() { + for method := range item.Operations() { + documented[method+" "+path] = struct{}{} + } + } + + if missing := routeDifference(implemented, documented); len(missing) > 0 { + t.Errorf("implemented routes missing from OpenAPI:\n%s", formatRoutes(missing)) + } + if missing := routeDifference(documented, implemented); len(missing) > 0 { + t.Errorf("OpenAPI routes missing from implementation:\n%s", formatRoutes(missing)) + } +} + +func routeDifference(left, right map[string]struct{}) []string { + var difference []string + for route := range left { + if _, ok := right[route]; !ok { + difference = append(difference, route) + } + } + sort.Strings(difference) + + return difference +} + +func formatRoutes(routes []string) string { + formatted := "" + for _, route := range routes { + formatted += fmt.Sprintf("- %s\n", route) + } + + return formatted +} diff --git a/internal/platform/httpserver/options.go b/internal/platform/httpserver/options.go new file mode 100644 index 0000000..845d9f7 --- /dev/null +++ b/internal/platform/httpserver/options.go @@ -0,0 +1,34 @@ +package httpserver + +import ( + "context" + "log/slog" +) + +type Readiness interface { + Check(context.Context) error +} + +type Option func(*Server) + +func WithReadiness(readiness Readiness) Option { + return func(server *Server) { + server.readiness = readiness + } +} + +func WithLogger(logger *slog.Logger) Option { + return func(server *Server) { + if logger != nil { + server.logger = logger + } + } +} + +func WithMetrics(metrics RequestMetrics) Option { + return func(server *Server) { + if metrics != nil { + server.metrics = metrics + } + } +} diff --git a/internal/platform/httpserver/providers.go b/internal/platform/httpserver/providers.go new file mode 100644 index 0000000..c268b4b --- /dev/null +++ b/internal/platform/httpserver/providers.go @@ -0,0 +1,104 @@ +package httpserver + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func (s *Server) registerProviders(api *gin.RouterGroup) { + group := api.Group("/me/provider-connections") + group.GET("", s.listProviderConnections) + group.POST("", s.createProviderConnection) + group.PATCH("/:connectionID", s.replaceProviderToken) + group.POST("/:connectionID/test", s.testProviderConnection) + group.POST("/:connectionID/default", s.defaultProviderConnection) + group.DELETE("/:connectionID", s.deleteProviderConnection) +} +func (s *Server) listProviderConnections(c *gin.Context) { + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.providers.List(c, a.UserID, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} +func (s *Server) createProviderConnection(c *gin.Context) { + var input struct { + ProviderType string `json:"providerType"` + BaseURL string `json:"baseUrl"` + Token string `json:"token"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + item, err := s.providers.Create(c, a.UserID, input.ProviderType, input.BaseURL, input.Token) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusCreated, item) +} +func (s *Server) replaceProviderToken(c *gin.Context) { + id, ok := parameterID(c, "connectionID") + if !ok { + return + } + var input struct { + Token string `json:"token"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + item, err := s.providers.ReplaceToken(c, a.UserID, id, input.Token) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) testProviderConnection(c *gin.Context) { + id, ok := parameterID(c, "connectionID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.providers.Test(c, a.UserID, id) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) defaultProviderConnection(c *gin.Context) { + id, ok := parameterID(c, "connectionID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.providers.SetDefault(c, a.UserID, id) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) deleteProviderConnection(c *gin.Context) { + id, ok := parameterID(c, "connectionID") + if !ok { + return + } + a, _ := actor(c) + if err := s.providers.Delete(c, a.UserID, id); err != nil { + failure(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/internal/platform/httpserver/repositories.go b/internal/platform/httpserver/repositories.go new file mode 100644 index 0000000..d399718 --- /dev/null +++ b/internal/platform/httpserver/repositories.go @@ -0,0 +1,234 @@ +package httpserver + +import ( + "net/http" + + repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func (s *Server) registerRepositories(api *gin.RouterGroup) { + group := api.Group("/workspaces/:workspaceID/repositories") + group.GET("", s.listRepositories) + group.POST("", s.createRepository) + group.GET("/:repositoryID", s.getRepository) + group.PATCH("/:repositoryID", s.updateRepository) + group.DELETE("/:repositoryID", s.deleteRepository) + group.POST("/:repositoryID/refresh", s.refreshRepository) + group.POST("/:repositoryID/archive", s.archiveRepository) + group.POST("/:repositoryID/restore", s.restoreRepository) + group.GET("/:repositoryID/operations", s.listRepositoryOperations) + group.GET("/:repositoryID/snapshots", s.listRepositorySnapshots) + group.POST("/:repositoryID/operations/:operationID/cancel", s.cancelRepositoryOperation) +} +func (s *Server) getRepository(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.repositories.Get(c, a, workspaceID, repositoryID) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} + +func (s *Server) listRepositorySnapshots(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.repositories.Snapshots(c, a, workspaceID, repositoryID, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} +func (s *Server) listRepositories(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.repositories.List(c, a, workspaceID, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} +func (s *Server) createRepository(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + var input struct { + Name string `json:"name"` + RemoteURL string `json:"remoteUrl"` + Ref string `json:"ref"` + ProviderConnectionID *uuid.UUID `json:"providerConnectionId"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + item, operation, err := s.repositories.Create(c, a, workspaceID, repository.CreateInput{Name: input.Name, RemoteURL: input.RemoteURL, Ref: input.Ref, ProviderConnectionID: input.ProviderConnectionID}) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusAccepted, gin.H{"repository": item, "operation": operation}) +} +func (s *Server) updateRepository(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + var input struct { + Name string `json:"name"` + RemoteURL string `json:"remoteUrl"` + Ref string `json:"ref"` + ProviderConnectionID *uuid.UUID `json:"providerConnectionId"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + item, operation, err := s.repositories.Update(c, a, workspaceID, repositoryID, repository.UpdateInput{ + Name: input.Name, RemoteURL: input.RemoteURL, Ref: input.Ref, ProviderConnectionID: input.ProviderConnectionID, + }) + if err != nil { + failure(c, err) + return + } + status := http.StatusOK + if operation != nil { + status = http.StatusAccepted + } + c.JSON(status, gin.H{"repository": item, "operation": operation}) +} +func (s *Server) refreshRepository(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + var input struct { + ProviderConnectionID *uuid.UUID `json:"providerConnectionId"` + } + if !bind(c, &input) { + return + } + a, _ := actor(c) + operation, err := s.repositories.Refresh(c, a, workspaceID, repositoryID, input.ProviderConnectionID) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusAccepted, operation) +} +func (s *Server) deleteRepository(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + a, _ := actor(c) + operation, err := s.repositories.Delete(c, a, workspaceID, repositoryID) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusAccepted, operation) +} +func (s *Server) archiveRepository(c *gin.Context) { s.setRepositoryArchive(c, true) } +func (s *Server) restoreRepository(c *gin.Context) { s.setRepositoryArchive(c, false) } +func (s *Server) setRepositoryArchive(c *gin.Context, archived bool) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + a, _ := actor(c) + item, err := s.repositories.Archive(c, a, workspaceID, repositoryID, archived) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, item) +} +func (s *Server) listRepositoryOperations(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + page, ok := pageRequest(c) + if !ok { + return + } + a, _ := actor(c) + items, err := s.repositories.Operations(c, a, workspaceID, repositoryID, page) + if err != nil { + failure(c, err) + return + } + c.JSON(http.StatusOK, items) +} +func (s *Server) cancelRepositoryOperation(c *gin.Context) { + workspaceID, ok := parameterID(c, "workspaceID") + if !ok { + return + } + repositoryID, ok := parameterID(c, "repositoryID") + if !ok { + return + } + operationID, ok := parameterID(c, "operationID") + if !ok { + return + } + a, _ := actor(c) + if err := s.repositories.Cancel(c, a, workspaceID, repositoryID, operationID); err != nil { + failure(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/internal/platform/httpserver/retention_metrics.go b/internal/platform/httpserver/retention_metrics.go new file mode 100644 index 0000000..a140c31 --- /dev/null +++ b/internal/platform/httpserver/retention_metrics.go @@ -0,0 +1,38 @@ +package httpserver + +import ( + "fmt" + "io" +) + +type retentionMetrics struct { + deletedRuns uint64 + purgedSnapshots uint64 + requeuedRepositories uint64 +} + +func (m *Metrics) AddRetentionProgress(deletedRuns, purgedSnapshots, requeuedRepositories int) { + m.mu.Lock() + if deletedRuns > 0 { + m.retention.deletedRuns += uint64(deletedRuns) + } + if purgedSnapshots > 0 { + m.retention.purgedSnapshots += uint64(purgedSnapshots) + } + if requeuedRepositories > 0 { + m.retention.requeuedRepositories += uint64(requeuedRepositories) + } + m.mu.Unlock() +} + +func writeRetentionMetrics(writer io.Writer, metrics retentionMetrics) { + _, _ = fmt.Fprintln(writer, "# HELP mooncode_retention_deleted_runs_total Expired terminal analysis runs deleted by retention cleanup.") + _, _ = fmt.Fprintln(writer, "# TYPE mooncode_retention_deleted_runs_total counter") + _, _ = fmt.Fprintf(writer, "mooncode_retention_deleted_runs_total %d\n", metrics.deletedRuns) + _, _ = fmt.Fprintln(writer, "# HELP mooncode_retention_purged_snapshots_total Unreferenced Git snapshot pins released by retention cleanup.") + _, _ = fmt.Fprintln(writer, "# TYPE mooncode_retention_purged_snapshots_total counter") + _, _ = fmt.Fprintf(writer, "mooncode_retention_purged_snapshots_total %d\n", metrics.purgedSnapshots) + _, _ = fmt.Fprintln(writer, "# HELP mooncode_retention_requeued_repositories_total Logically deleted repositories requeued for physical purge.") + _, _ = fmt.Fprintln(writer, "# TYPE mooncode_retention_requeued_repositories_total counter") + _, _ = fmt.Fprintf(writer, "mooncode_retention_requeued_repositories_total %d\n", metrics.requeuedRepositories) +} diff --git a/internal/platform/httpserver/server.go b/internal/platform/httpserver/server.go new file mode 100644 index 0000000..fd1812d --- /dev/null +++ b/internal/platform/httpserver/server.go @@ -0,0 +1,98 @@ +package httpserver + +import ( + "log/slog" + "net/http" + "time" + + openapidoc "github.com/fuchencong/mooncode/api/openapi" + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + channel "github.com/fuchencong/mooncode/internal/channel/biz" + channelcommand "github.com/fuchencong/mooncode/internal/channel/command" + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/gin-gonic/gin" +) + +type Config struct { + LogoutURL string + RegistrationMode string + TermsVersion string + PrivacyVersion string + TrustedProxyCIDRs []string + MaxBodyBytes int64 + RequestTimeout time.Duration +} + +type Server struct { + config Config + identity *identity.Service + providers *identity.ProviderService + repositories *repository.Service + analyses *analysis.Service + channels *channel.Service + commands *channelcommand.Service + readiness Readiness + logger *slog.Logger + metrics RequestMetrics + csrf *auth.CSRF + router *gin.Engine +} + +func New(config Config, identityService *identity.Service, providers *identity.ProviderService, repositories *repository.Service, analyses *analysis.Service, channels *channel.Service, commands *channelcommand.Service, csrf *auth.CSRF, options ...Option) (*Server, error) { + if config.MaxBodyBytes <= 0 { + config.MaxBodyBytes = 1 << 20 + } + if config.RequestTimeout <= 0 { + config.RequestTimeout = 30 * time.Second + } + trusted, err := auth.NewTrustedProxies(config.TrustedProxyCIDRs) + if err != nil { + return nil, err + } + server := &Server{config: config, identity: identityService, providers: providers, repositories: repositories, analyses: analyses, channels: channels, commands: commands, csrf: csrf, logger: slog.Default(), metrics: NewMetrics()} + for _, option := range options { + option(server) + } + router := gin.New() + if err := router.SetTrustedProxies(config.TrustedProxyCIDRs); err != nil { + return nil, err + } + router.Use(auth.RequestIDMiddleware(), accessLog(server.logger), observeRequests(server.metrics), gin.Recovery(), securityHeaders(), limitBody(config.MaxBodyBytes), requestTimeout(config.RequestTimeout)) + router.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + router.GET("/readyz", server.ready) + router.GET("/metrics", gin.WrapH(server.metrics.Handler())) + router.GET("/api/openapi.yaml", server.openAPI) + api := router.Group("/api/v1") + api.Use(auth.Require(identityService, trusted), csrf.Middleware()) + active := api.Group("") + active.Use(requireActiveAccount()) + server.registerIdentity(api, active) + server.registerProviders(active) + server.registerRepositories(active) + server.registerAnalysis(active) + server.registerChannels(active) + server.router = router + return server, nil +} + +func (s *Server) openAPI(c *gin.Context) { + document, err := openapidoc.Document() + if err != nil { + failure(c, err) + return + } + c.Data(http.StatusOK, "application/yaml; charset=utf-8", document) +} + +func (s *Server) Handler() http.Handler { return s.router } + +func (s *Server) ready(c *gin.Context) { + if s.readiness == nil || s.readiness.Check(c.Request.Context()) != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ready"}) +} diff --git a/internal/platform/httpserver/server_test.go b/internal/platform/httpserver/server_test.go new file mode 100644 index 0000000..c521dc1 --- /dev/null +++ b/internal/platform/httpserver/server_test.go @@ -0,0 +1,114 @@ +package httpserver + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/gin-gonic/gin" +) + +type readinessFunc func(context.Context) error + +func (check readinessFunc) Check(ctx context.Context) error { + return check(ctx) +} + +func testHTTPConfig() Config { + return Config{TrustedProxyCIDRs: []string{"192.0.2.1/32"}} +} + +func TestHealthAndReadinessAreSeparateAndHardened(t *testing.T) { + server, err := New( + testHTTPConfig(), nil, nil, nil, nil, nil, nil, + auth.NewCSRF([]byte("01234567890123456789012345678901")), + WithReadiness(readinessFunc(func(context.Context) error { return nil })), + ) + if err != nil { + t.Fatal(err) + } + + for _, endpoint := range []string{"/healthz", "/readyz"} { + request := httptest.NewRequest(http.MethodGet, endpoint, nil) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("%s status = %d, body = %s", endpoint, response.Code, response.Body.String()) + } + if response.Header().Get("X-Content-Type-Options") != "nosniff" || response.Header().Get("X-Frame-Options") != "DENY" { + t.Fatalf("%s is missing security headers: %v", endpoint, response.Header()) + } + } +} + +func TestReadinessFailureDoesNotExposeInternalError(t *testing.T) { + server, err := New( + testHTTPConfig(), nil, nil, nil, nil, nil, nil, + auth.NewCSRF([]byte("01234567890123456789012345678901")), + WithReadiness(readinessFunc(func(context.Context) error { return errors.New("database password leaked") })), + ) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/readyz", nil) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusServiceUnavailable || strings.Contains(response.Body.String(), "password") { + t.Fatalf("readiness response = %d %s", response.Code, response.Body.String()) + } +} + +func TestBodyLimitUsesStableErrorContract(t *testing.T) { + router := gin.New() + router.Use(auth.RequestIDMiddleware(), limitBody(8)) + router.POST("/", func(c *gin.Context) { + var input map[string]any + if !bind(c, &input) { + return + } + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"value":"too large"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusRequestEntityTooLarge || !strings.Contains(response.Body.String(), `"code":"request.body_too_large"`) || !strings.Contains(response.Body.String(), `"requestId":`) { + t.Fatalf("body limit response = %d %s", response.Code, response.Body.String()) + } +} + +func TestRequestTimeoutUsesStableErrorContract(t *testing.T) { + router := gin.New() + router.Use(auth.RequestIDMiddleware(), requestTimeout(time.Millisecond)) + router.GET("/", func(c *gin.Context) { + <-c.Request.Context().Done() + failure(c, c.Request.Context().Err()) + }) + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusGatewayTimeout || !strings.Contains(response.Body.String(), `"code":"request.timeout"`) { + t.Fatalf("timeout response = %d %s", response.Code, response.Body.String()) + } +} + +func TestUnexpectedErrorIsNotExposed(t *testing.T) { + router := gin.New() + router.Use(auth.RequestIDMiddleware()) + router.GET("/", func(c *gin.Context) { failure(c, errors.New("private database detail")) }) + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusInternalServerError || !strings.Contains(response.Body.String(), `"code":"internal.error"`) || strings.Contains(response.Body.String(), "database detail") { + t.Fatalf("internal error response = %d %s", response.Code, response.Body.String()) + } +} diff --git a/internal/platform/httpserver/task_metrics.go b/internal/platform/httpserver/task_metrics.go new file mode 100644 index 0000000..8d2c8ca --- /dev/null +++ b/internal/platform/httpserver/task_metrics.go @@ -0,0 +1,62 @@ +package httpserver + +import ( + "fmt" + "io" + "strconv" + "strings" + "time" +) + +var knownTasks = map[string]struct{}{ + "repository-operation": {}, + "analysis-run": {}, + "notification-delivery": {}, + "retention-scheduler": {}, + "retention-cleanup": {}, +} + +func (m *Metrics) ObserveTask(task, status string, duration time.Duration) { + if _, ok := knownTasks[task]; !ok { + task = "unknown" + } + if status != "succeeded" && status != "failed" { + status = "unknown" + } + key := task + "\x00" + status + seconds := duration.Seconds() + + m.mu.Lock() + metric := m.tasks[key] + if metric == nil { + metric = &requestMetric{} + m.tasks[key] = metric + } + metric.count++ + metric.sum += seconds + for index, upperBound := range durationBuckets { + if seconds <= upperBound { + metric.buckets[index]++ + } + } + m.mu.Unlock() +} + +func writeTaskMetrics(writer io.Writer, keys []string, metrics map[string]requestMetric) { + _, _ = fmt.Fprintln(writer, "# HELP mooncode_hatchet_task_runs_total Total MoonCode Hatchet task attempts.") + _, _ = fmt.Fprintln(writer, "# TYPE mooncode_hatchet_task_runs_total counter") + _, _ = fmt.Fprintln(writer, "# HELP mooncode_hatchet_task_duration_seconds MoonCode Hatchet task attempt duration in seconds.") + _, _ = fmt.Fprintln(writer, "# TYPE mooncode_hatchet_task_duration_seconds histogram") + for _, key := range keys { + parts := strings.Split(key, "\x00") + labels := fmt.Sprintf(`task=%q,status=%q`, parts[0], parts[1]) + metric := metrics[key] + _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_runs_total{%s} %d\n", labels, metric.count) + for index, upperBound := range durationBuckets { + _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_duration_seconds_bucket{%s,le=%q} %d\n", labels, strconv.FormatFloat(upperBound, 'f', -1, 64), metric.buckets[index]) + } + _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_duration_seconds_bucket{%s,le=\"+Inf\"} %d\n", labels, metric.count) + _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_duration_seconds_sum{%s} %g\n", labels, metric.sum) + _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_duration_seconds_count{%s} %d\n", labels, metric.count) + } +} diff --git a/internal/platform/pagination/pagination.go b/internal/platform/pagination/pagination.go new file mode 100644 index 0000000..f018c16 --- /dev/null +++ b/internal/platform/pagination/pagination.go @@ -0,0 +1,104 @@ +package pagination + +import ( + "encoding/base64" + "encoding/binary" + "fmt" + "strconv" + "time" + + "github.com/google/uuid" +) + +const ( + DefaultLimit int32 = 50 + MaxLimit int32 = 100 + cursorSize = 24 +) + +type Cursor struct { + Time time.Time + ID uuid.UUID +} + +type Request struct { + Limit int32 + After *Cursor +} + +type Page[T any] struct { + Items []T `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} + +func Parse(rawCursor, rawLimit string) (Request, error) { + request := Request{Limit: DefaultLimit} + if rawLimit != "" { + value, err := strconv.ParseInt(rawLimit, 10, 32) + if err != nil || value < 1 || value > int64(MaxLimit) { + return Request{}, fmt.Errorf("page limit must be between 1 and %d", MaxLimit) + } + request.Limit = int32(value) + } + if rawCursor == "" { + return request, nil + } + + cursor, err := Decode(rawCursor) + if err != nil { + return Request{}, err + } + request.After = &cursor + + return request, nil +} + +func Decode(value string) (Cursor, error) { + decoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(decoded) != cursorSize { + return Cursor{}, fmt.Errorf("page cursor is invalid") + } + + var id uuid.UUID + copy(id[:], decoded[8:]) + nanoseconds := int64(binary.BigEndian.Uint64(decoded[:8])) + if id == uuid.Nil { + return Cursor{}, fmt.Errorf("page cursor is invalid") + } + + return Cursor{Time: time.Unix(0, nanoseconds).UTC(), ID: id}, nil +} + +func Encode(at time.Time, id uuid.UUID) string { + encoded := make([]byte, cursorSize) + binary.BigEndian.PutUint64(encoded[:8], uint64(at.UnixNano())) + copy(encoded[8:], id[:]) + + return base64.RawURLEncoding.EncodeToString(encoded) +} + +func Build[T any](items []T, limit int32, position func(T) (time.Time, uuid.UUID)) Page[T] { + limit = normalizedLimit(limit) + page := Page[T]{Items: items} + if len(items) <= int(limit) { + return page + } + + page.Items = items[:limit] + at, id := position(page.Items[len(page.Items)-1]) + page.NextCursor = Encode(at, id) + + return page +} + +func (r Request) QueryLimit() int32 { + return normalizedLimit(r.Limit) + 1 +} + +func normalizedLimit(limit int32) int32 { + if limit < 1 || limit > MaxLimit { + return DefaultLimit + } + + return limit +} diff --git a/internal/platform/pagination/pagination_test.go b/internal/platform/pagination/pagination_test.go new file mode 100644 index 0000000..8219ff4 --- /dev/null +++ b/internal/platform/pagination/pagination_test.go @@ -0,0 +1,51 @@ +package pagination + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestCursorRoundTripAndPageBoundary(t *testing.T) { + type item struct { + ID uuid.UUID + CreatedAt time.Time + } + items := []item{ + {ID: uuid.New(), CreatedAt: time.Now().UTC().Truncate(time.Microsecond)}, + {ID: uuid.New(), CreatedAt: time.Now().UTC().Add(-time.Second).Truncate(time.Microsecond)}, + {ID: uuid.New(), CreatedAt: time.Now().UTC().Add(-2 * time.Second).Truncate(time.Microsecond)}, + } + page := Build(items, 2, func(value item) (time.Time, uuid.UUID) { return value.CreatedAt, value.ID }) + if len(page.Items) != 2 || page.NextCursor == "" { + t.Fatalf("unexpected page: %+v", page) + } + cursor, err := Decode(page.NextCursor) + if err != nil { + t.Fatal(err) + } + if cursor.ID != items[1].ID || !cursor.Time.Equal(items[1].CreatedAt) { + t.Fatalf("cursor = %+v, want %+v", cursor, items[1]) + } +} + +func TestParseRejectsInvalidCursorAndLimit(t *testing.T) { + for _, input := range []struct{ cursor, limit string }{ + {cursor: "not-base64"}, + {limit: "0"}, + {limit: "101"}, + {limit: "not-a-number"}, + } { + if _, err := Parse(input.cursor, input.limit); err == nil { + t.Fatalf("Parse(%q, %q) succeeded", input.cursor, input.limit) + } + } +} + +func TestZeroValueRequestUsesDefaultLimit(t *testing.T) { + request := Request{} + if request.QueryLimit() != DefaultLimit+1 { + t.Fatalf("QueryLimit() = %d, want %d", request.QueryLimit(), DefaultLimit+1) + } +} diff --git a/internal/platform/secret/cipher.go b/internal/platform/secret/cipher.go new file mode 100644 index 0000000..e15d308 --- /dev/null +++ b/internal/platform/secret/cipher.go @@ -0,0 +1,65 @@ +package secret + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + "io" +) + +type Cipher interface { + Encrypt(plaintext []byte) (ciphertext, nonce []byte, keyVersion int, err error) + Decrypt(ciphertext, nonce []byte, keyVersion int) ([]byte, error) +} + +type Option func(*AESGCM) + +type AESGCM struct { + aead cipher.AEAD + keyVersion int + random io.Reader +} + +func WithRandom(random io.Reader) Option { + return func(c *AESGCM) { c.random = random } +} + +func NewAESGCM(key []byte, keyVersion int, options ...Option) (*AESGCM, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create GCM: %w", err) + } + + value := &AESGCM{aead: aead, keyVersion: keyVersion, random: rand.Reader} + for _, option := range options { + option(value) + } + + return value, nil +} + +func (c *AESGCM) Encrypt(plaintext []byte) ([]byte, []byte, int, error) { + nonce := make([]byte, c.aead.NonceSize()) + if _, err := io.ReadFull(c.random, nonce); err != nil { + return nil, nil, 0, fmt.Errorf("generate nonce: %w", err) + } + + return c.aead.Seal(nil, nonce, plaintext, nil), nonce, c.keyVersion, nil +} + +func (c *AESGCM) Decrypt(ciphertext, nonce []byte, keyVersion int) ([]byte, error) { + if keyVersion != c.keyVersion { + return nil, fmt.Errorf("unsupported key version %d", keyVersion) + } + plaintext, err := c.aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("decrypt secret: %w", err) + } + + return plaintext, nil +} diff --git a/internal/platform/secret/cipher_test.go b/internal/platform/secret/cipher_test.go new file mode 100644 index 0000000..ee1b154 --- /dev/null +++ b/internal/platform/secret/cipher_test.go @@ -0,0 +1,21 @@ +package secret + +import "testing" + +func TestAESGCMRoundTrip(t *testing.T) { + cipher, err := NewAESGCM(make([]byte, 32), 3) + if err != nil { + t.Fatal(err) + } + ciphertext, nonce, version, err := cipher.Encrypt([]byte("github_pat_secret")) + if err != nil { + t.Fatal(err) + } + plaintext, err := cipher.Decrypt(ciphertext, nonce, version) + if err != nil { + t.Fatal(err) + } + if string(plaintext) != "github_pat_secret" { + t.Fatalf("unexpected plaintext %q", plaintext) + } +} diff --git a/internal/repository/biz/failure.go b/internal/repository/biz/failure.go new file mode 100644 index 0000000..e093481 --- /dev/null +++ b/internal/repository/biz/failure.go @@ -0,0 +1,6 @@ +package biz + +type Failure struct { + Code string + Message string +} diff --git a/internal/repository/biz/model.go b/internal/repository/biz/model.go new file mode 100644 index 0000000..c2f06cf --- /dev/null +++ b/internal/repository/biz/model.go @@ -0,0 +1,73 @@ +package biz + +import ( + "time" + + "github.com/google/uuid" +) + +type Snapshot struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repositoryId"` + CommitSHA string `json:"commitSha"` + SourceRef string `json:"sourceRef"` + AuthorName string `json:"authorName,omitempty"` + AuthoredAt *time.Time `json:"authoredAt,omitempty"` + Title string `json:"title,omitempty"` + SourceState string `json:"sourceState"` + CreatedAt time.Time `json:"createdAt"` +} + +type Repository struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspaceId"` + ProviderType string `json:"providerType"` + Name string `json:"name"` + RemoteURL string `json:"remoteUrl"` + NormalizedURL string `json:"normalizedUrl"` + ConfiguredRef string `json:"ref"` + ConfigVersion int64 `json:"configVersion"` + Status string `json:"status"` + CurrentSnapshotID *uuid.UUID `json:"currentSnapshotId,omitempty"` + CurrentSnapshot *Snapshot `json:"currentSnapshot,omitempty"` + MirrorSizeBytes int64 `json:"mirrorSizeBytes"` + LastSyncAt *time.Time `json:"lastSyncAt,omitempty"` + LastErrorCode string `json:"lastErrorCode,omitempty"` + LastErrorMessage string `json:"lastErrorMessage,omitempty"` + ArchivedAt *time.Time `json:"archivedAt,omitempty"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Operation struct { + ID uuid.UUID `json:"id"` + RepositoryID uuid.UUID `json:"repositoryId"` + ActorUserID uuid.UUID `json:"actorUserId"` + ProviderConnectionID *uuid.UUID `json:"providerConnectionId,omitempty"` + CredentialVersion int64 `json:"credentialVersion,omitempty"` + RepositoryVersion int64 `json:"repositoryVersion"` + Kind string `json:"kind"` + RequestedProviderType string `json:"requestedProviderType"` + RequestedRemoteURL string `json:"requestedRemoteUrl"` + RequestedNormalizedURL string `json:"requestedNormalizedUrl"` + RequestedRef string `json:"requestedRef"` + PreviousProviderType string `json:"-"` + PreviousRemoteURL string `json:"-"` + PreviousNormalizedURL string `json:"-"` + PreviousRef string `json:"-"` + Status string `json:"status"` + Outcome string `json:"outcome,omitempty"` + ResolvedCommitSHA string `json:"resolvedCommitSha,omitempty"` + SnapshotID *uuid.UUID `json:"snapshotId,omitempty"` + WorkflowRunID string `json:"workflowRunId,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + CreatedAt time.Time `json:"createdAt"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` +} + +type WorkItem struct { + Repository Repository + Operation Operation +} diff --git a/internal/repository/biz/service.go b/internal/repository/biz/service.go new file mode 100644 index 0000000..8a63511 --- /dev/null +++ b/internal/repository/biz/service.go @@ -0,0 +1,290 @@ +package biz + +import ( + "context" + "strings" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" +) + +type Authorizer interface { + Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) +} + +type ProviderConnections interface { + Get(context.Context, uuid.UUID, uuid.UUID) (identity.ProviderConnection, error) +} + +type Service struct { + store Store + authorizer Authorizer + providers ProviderConnections + canceller Canceller + git gitrepo.Manager +} + +func NewService(store Store, authorizer Authorizer, providers ProviderConnections, canceller Canceller, git gitrepo.Manager) *Service { + return &Service{store: store, authorizer: authorizer, providers: providers, canceller: canceller, git: git} +} + +type CreateInput struct { + Name string + RemoteURL string + Ref string + ProviderConnectionID *uuid.UUID +} + +type UpdateInput struct { + Name string + RemoteURL string + Ref string + ProviderConnectionID *uuid.UUID +} + +func (s *Service) Create(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, input CreateInput) (Repository, Operation, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Repository{}, Operation{}, err + } + remote, connection, err := s.resolveRemote(ctx, actor.UserID, input.ProviderConnectionID, input.RemoteURL) + if err != nil { + return Repository{}, Operation{}, err + } + name := strings.TrimSpace(input.Name) + if name == "" { + name = remote.Name + } + configuredRef := strings.TrimSpace(input.Ref) + if configuredRef == "" { + return Repository{}, Operation{}, fault.New(fault.Invalid, "repository.ref_required", "Repository ref is required") + } + repositoryID, operationID := uuid.New(), uuid.New() + repository := Repository{ID: repositoryID, WorkspaceID: workspaceID, ProviderType: remote.ProviderType, Name: name, RemoteURL: remote.URL, NormalizedURL: remote.Normalized, ConfiguredRef: configuredRef, Status: "provisioning"} + operation := sourceOperation(actor, repository, connection, remote, configuredRef, "provision", 1) + operation.ID = operationID + repository, operation, err = s.store.Create(ctx, repository, operation) + if err != nil { + return Repository{}, Operation{}, err + } + return repository, operation, nil +} + +func (s *Service) List(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[Repository], error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return pagination.Page[Repository]{}, err + } + + return s.store.List(ctx, workspaceID, page) +} + +func (s *Service) Get(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID) (Repository, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Repository{}, err + } + + return s.store.Get(ctx, workspaceID, repositoryID) +} + +func (s *Service) Snapshots(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[Snapshot], error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return pagination.Page[Snapshot]{}, err + } + if _, err := s.store.Get(ctx, workspaceID, repositoryID); err != nil { + return pagination.Page[Snapshot]{}, err + } + + return s.store.Snapshots(ctx, workspaceID, repositoryID, page) +} + +func (s *Service) Snapshot(ctx context.Context, actor auth.Actor, workspaceID, repositoryID, snapshotID uuid.UUID) (Snapshot, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Snapshot{}, err + } + + return s.store.Snapshot(ctx, workspaceID, repositoryID, snapshotID) +} + +func (s *Service) Update(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, input UpdateInput) (Repository, *Operation, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Repository{}, nil, err + } + repository, err := s.store.Get(ctx, workspaceID, repositoryID) + if err != nil { + return Repository{}, nil, err + } + name := strings.TrimSpace(input.Name) + if name == "" { + name = repository.Name + } + configuredRef := strings.TrimSpace(input.Ref) + if configuredRef == "" { + configuredRef = repository.ConfiguredRef + } + remoteURL := strings.TrimSpace(input.RemoteURL) + if remoteURL == "" { + remoteURL = repository.RemoteURL + } + sourceChanged := configuredRef != repository.ConfiguredRef || remoteURL != repository.RemoteURL + if !sourceChanged { + repository, err = s.store.Update(ctx, workspaceID, repositoryID, name) + if err != nil { + return Repository{}, nil, err + } + + return repository, nil, nil + } + if repository.ArchivedAt != nil || repository.Status == "deleting" || repository.Status == "provisioning" || repository.Status == "syncing" { + return Repository{}, nil, fault.New(fault.Conflict, "repository.update_unavailable", "Repository source is not available for update") + } + remote, connection, err := s.resolveRemote(ctx, actor.UserID, input.ProviderConnectionID, remoteURL) + if err != nil { + return Repository{}, nil, err + } + operation := sourceOperation(actor, repository, connection, remote, configuredRef, "update", repository.ConfigVersion+1) + repository, operation, err = s.store.UpdateWithOperation(ctx, workspaceID, repositoryID, name, operation) + if err != nil { + return Repository{}, nil, err + } + + return repository, &operation, nil +} + +func (s *Service) Refresh(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, connectionID *uuid.UUID) (Operation, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Operation{}, err + } + repository, err := s.store.Get(ctx, workspaceID, repositoryID) + if err != nil { + return Operation{}, err + } + if repository.ArchivedAt != nil || repository.Status == "deleting" || repository.Status == "provisioning" || repository.Status == "syncing" { + return Operation{}, fault.New(fault.Conflict, "repository.refresh_unavailable", "Repository is not available for refresh") + } + + return s.newRemoteOperation(ctx, actor, repository, connectionID, "refresh") +} + +func (s *Service) newRemoteOperation(ctx context.Context, actor auth.Actor, repository Repository, connectionID *uuid.UUID, kind string) (Operation, error) { + remote, connection, err := s.resolveRemote(ctx, actor.UserID, connectionID, repository.RemoteURL) + if err != nil { + return Operation{}, err + } + if remote.ProviderType != repository.ProviderType || remote.Normalized != repository.NormalizedURL { + return Operation{}, fault.New(fault.Invalid, "provider.connection_mismatch", "The selected provider connection does not match the repository") + } + operation := sourceOperation(actor, repository, connection, remote, repository.ConfiguredRef, kind, repository.ConfigVersion) + + return s.store.CreateOperation(ctx, repository.WorkspaceID, operation) +} + +func (s *Service) resolveRemote(ctx context.Context, userID uuid.UUID, connectionID *uuid.UUID, remoteURL string) (gitrepo.Remote, *identity.ProviderConnection, error) { + if connectionID == nil { + remote, err := gitrepo.NormalizePublicRemote(remoteURL) + if err != nil { + return gitrepo.Remote{}, nil, fault.Wrap(fault.Invalid, "repository.remote_invalid", "Repository URL is invalid or requires a provider connection", err) + } + + return remote, nil, nil + } + + connection, err := s.providers.Get(ctx, userID, *connectionID) + if err != nil { + return gitrepo.Remote{}, nil, fault.New(fault.Invalid, "provider.connection_unavailable", "Provider connection is not available to the current user") + } + if connection.Status != "active" { + return gitrepo.Remote{}, nil, fault.New(fault.Invalid, "provider.connection_inactive", "Provider connection is not active") + } + remote, err := gitrepo.NormalizeRemote(connection.ProviderType, connection.BaseURL, remoteURL) + if err != nil { + return gitrepo.Remote{}, nil, fault.Wrap(fault.Invalid, "repository.remote_invalid", "Repository URL is invalid", err) + } + + return remote, &connection, nil +} + +func sourceOperation(actor auth.Actor, repository Repository, connection *identity.ProviderConnection, remote gitrepo.Remote, requestedRef, kind string, repositoryVersion int64) Operation { + operation := Operation{ + ID: uuid.New(), RepositoryID: repository.ID, ActorUserID: actor.UserID, + RepositoryVersion: repositoryVersion, Kind: kind, + RequestedProviderType: remote.ProviderType, RequestedRemoteURL: remote.URL, + RequestedNormalizedURL: remote.Normalized, RequestedRef: requestedRef, + PreviousProviderType: repository.ProviderType, PreviousRemoteURL: repository.RemoteURL, + PreviousNormalizedURL: repository.NormalizedURL, PreviousRef: repository.ConfiguredRef, + Status: "queued", + } + if connection != nil { + connectionID := connection.ID + operation.ProviderConnectionID = &connectionID + operation.CredentialVersion = connection.CredentialVersion + } + + return operation +} + +func (s *Service) Delete(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID) (Operation, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Operation{}, err + } + repository, err := s.store.Get(ctx, workspaceID, repositoryID) + if err != nil { + return Operation{}, err + } + if repository.Status == "provisioning" || repository.Status == "syncing" || repository.Status == "deleting" { + return Operation{}, fault.New(fault.Conflict, "repository.deletion_unavailable", "Repository cannot be deleted while an operation is active") + } + operation := Operation{ + ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: actor.UserID, + RepositoryVersion: repository.ConfigVersion, Kind: "purge", + RequestedProviderType: repository.ProviderType, RequestedRemoteURL: repository.RemoteURL, + RequestedNormalizedURL: repository.NormalizedURL, RequestedRef: repository.ConfiguredRef, + PreviousProviderType: repository.ProviderType, PreviousRemoteURL: repository.RemoteURL, + PreviousNormalizedURL: repository.NormalizedURL, PreviousRef: repository.ConfiguredRef, + Status: "queued", + } + _, operation, err = s.store.RequestDeletion(ctx, workspaceID, repositoryID, operation) + if err != nil { + return Operation{}, err + } + return operation, nil +} + +func (s *Service) Archive(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, archived bool) (Repository, error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return Repository{}, err + } + + return s.store.Archive(ctx, workspaceID, repositoryID, archived) +} + +func (s *Service) Operations(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[Operation], error) { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return pagination.Page[Operation]{}, err + } + if _, err := s.store.Get(ctx, workspaceID, repositoryID); err != nil { + return pagination.Page[Operation]{}, err + } + + return s.store.ListOperations(ctx, repositoryID, page) +} + +func (s *Service) Cancel(ctx context.Context, actor auth.Actor, workspaceID, repositoryID, operationID uuid.UUID) error { + if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil { + return err + } + if _, err := s.store.Get(ctx, workspaceID, repositoryID); err != nil { + return err + } + operation, ok, err := s.store.CancelOperation(ctx, workspaceID, actor.UserID, repositoryID, operationID) + if err != nil || !ok { + return fault.New(fault.Conflict, "repository.operation_not_cancellable", "Repository operation cannot be cancelled") + } + if operation.WorkflowRunID != "" { + _ = s.canceller.CancelRepository(ctx, operation.WorkflowRunID) + } + + return nil +} diff --git a/internal/repository/biz/service_test.go b/internal/repository/biz/service_test.go new file mode 100644 index 0000000..952685c --- /dev/null +++ b/internal/repository/biz/service_test.go @@ -0,0 +1,306 @@ +package biz + +import ( + "context" + "errors" + "testing" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" +) + +type repositoryAuthorizer struct{} + +func (repositoryAuthorizer) Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) { + return identity.Membership{Role: "member"}, nil +} + +type providerConnections struct { + connection identity.ProviderConnection + err error + calls int + userID uuid.UUID +} + +func (p *providerConnections) Get(_ context.Context, userID, _ uuid.UUID) (identity.ProviderConnection, error) { + p.calls++ + p.userID = userID + return p.connection, p.err +} + +type repositoryStore struct { + repository Repository + operation Operation + getErr error + updated bool + updatedWithOperation bool + deletionRequested bool + cancelled bool +} + +func (s *repositoryStore) Create(_ context.Context, repository Repository, operation Operation) (Repository, Operation, error) { + s.repository = repository + s.operation = operation + + return repository, operation, nil +} +func (s *repositoryStore) Get(context.Context, uuid.UUID, uuid.UUID) (Repository, error) { + return s.repository, s.getErr +} +func (s *repositoryStore) List(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Repository], error) { + return pagination.Page[Repository]{Items: []Repository{s.repository}}, nil +} +func (s *repositoryStore) Update(_ context.Context, _, _ uuid.UUID, name string) (Repository, error) { + s.updated = true + s.repository.Name = name + return s.repository, nil +} +func (s *repositoryStore) UpdateWithOperation(_ context.Context, _, _ uuid.UUID, name string, operation Operation) (Repository, Operation, error) { + s.updatedWithOperation = true + s.repository.Name = name + s.repository.ProviderType = operation.RequestedProviderType + s.repository.RemoteURL = operation.RequestedRemoteURL + s.repository.NormalizedURL = operation.RequestedNormalizedURL + s.repository.ConfiguredRef = operation.RequestedRef + s.operation = operation + return s.repository, operation, nil +} +func (s *repositoryStore) Archive(context.Context, uuid.UUID, uuid.UUID, bool) (Repository, error) { + return s.repository, nil +} +func (s *repositoryStore) RequestDeletion(_ context.Context, _, _ uuid.UUID, operation Operation) (Repository, Operation, error) { + s.deletionRequested = true + s.operation = operation + return s.repository, operation, nil +} +func (s *repositoryStore) Delete(context.Context, uuid.UUID, uuid.UUID) error { return nil } +func (s *repositoryStore) CreateOperation(_ context.Context, _ uuid.UUID, operation Operation) (Operation, error) { + s.operation = operation + return operation, nil +} +func (s *repositoryStore) ListOperations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Operation], error) { + return pagination.Page[Operation]{Items: []Operation{s.operation}}, nil +} +func (s *repositoryStore) Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (Snapshot, error) { + return Snapshot{}, nil +} +func (s *repositoryStore) Snapshots(context.Context, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[Snapshot], error) { + return pagination.Page[Snapshot]{}, nil +} +func (s *repositoryStore) GetWorkItem(context.Context, uuid.UUID) (WorkItem, error) { + return WorkItem{Repository: s.repository, Operation: s.operation}, nil +} +func (s *repositoryStore) StartOperation(context.Context, uuid.UUID) (Operation, error) { + return s.operation, nil +} +func (s *repositoryStore) CompleteSync(context.Context, WorkItem, uuid.UUID, gitrepo.Snapshot) (Operation, error) { + return s.operation, nil +} +func (*repositoryStore) HasRetainedAnalysis(context.Context, uuid.UUID) (bool, error) { + return false, nil +} +func (*repositoryStore) CompleteRetainedDeletion(context.Context, WorkItem) error { return nil } +func (s *repositoryStore) CompletePurge(context.Context, WorkItem) error { return nil } +func (s *repositoryStore) FailOperation(context.Context, WorkItem, Failure) error { return nil } +func (s *repositoryStore) CancelOperation(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) (Operation, bool, error) { + s.cancelled = true + return s.operation, true, nil +} + +type repositoryCanceller struct { + called *string +} + +func (c repositoryCanceller) CancelRepository(_ context.Context, workflowRunID string) error { + if c.called != nil { + *c.called = workflowRunID + } + + return nil +} + +func repositoryConnectionID(value uuid.UUID) *uuid.UUID { + return &value +} + +func TestCreatePublicRepositoryWithoutProviderConnection(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + workspaceID := uuid.New() + store := &repositoryStore{} + providers := &providerConnections{} + service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil) + + created, operation, err := service.Create(context.Background(), actor, workspaceID, CreateInput{ + RemoteURL: "https://github.com/example/public-repo", + Ref: "main", + }) + if err != nil { + t.Fatal(err) + } + if providers.calls != 0 || operation.ProviderConnectionID != nil || operation.CredentialVersion != 0 { + t.Fatalf("public repository loaded a provider connection: operation=%#v calls=%d", operation, providers.calls) + } + if created.ProviderType != "github" || created.Name != "public-repo" || created.RemoteURL != "https://github.com/example/public-repo.git" { + t.Fatalf("unexpected public repository: %#v", created) + } +} + +func TestUpdatePublicRepositorySourceWithoutProviderConnection(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Name: "repo", RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo", ConfiguredRef: "main", ConfigVersion: 4, Status: "ready"} + store := &repositoryStore{repository: repository} + providers := &providerConnections{} + service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil) + + updated, operation, err := service.Update(context.Background(), actor, repository.WorkspaceID, repository.ID, UpdateInput{ + RemoteURL: "https://github.com/example/next-repo", + Ref: "release", + }) + if err != nil { + t.Fatal(err) + } + if providers.calls != 0 || operation == nil || operation.ProviderConnectionID != nil { + t.Fatalf("public source update loaded a provider connection: operation=%#v calls=%d", operation, providers.calls) + } + if updated.RemoteURL != "https://github.com/example/next-repo.git" || updated.ConfiguredRef != "release" { + t.Fatalf("unexpected public source update: %#v", updated) + } +} + +func TestUpdateSourceDoesNotMutateRepositoryWithoutPersonalConnection(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Name: "repo", RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo", ConfiguredRef: "main", ConfigVersion: 6, Status: "ready"} + store := &repositoryStore{repository: repository} + providers := &providerConnections{err: errors.New("not found")} + service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil) + + if _, _, err := service.Update(context.Background(), actor, repository.WorkspaceID, repository.ID, UpdateInput{Name: repository.Name, Ref: "release", ProviderConnectionID: repositoryConnectionID(uuid.New())}); err == nil { + t.Fatal("expected update to reject an unavailable connection") + } + if store.updated || store.updatedWithOperation || store.repository.ConfiguredRef != "main" { + t.Fatalf("repository mutated before credential validation: %#v", store) + } + if providers.userID != actor.UserID { + t.Fatalf("provider lookup used user %s, want %s", providers.userID, actor.UserID) + } +} + +func TestUpdateSourceCreatesOperationWithActorCredentialVersion(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + connectionID := uuid.New() + repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Name: "repo", RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo", ConfiguredRef: "main", ConfigVersion: 6, Status: "ready"} + store := &repositoryStore{repository: repository} + providers := &providerConnections{connection: identity.ProviderConnection{ID: connectionID, ProviderType: "github", BaseURL: "https://github.com", CredentialVersion: 7, Status: "active"}} + service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil) + + updated, operation, err := service.Update(context.Background(), actor, repository.WorkspaceID, repository.ID, UpdateInput{Name: repository.Name, RemoteURL: "https://github.com/example/new-repo", Ref: "release", ProviderConnectionID: repositoryConnectionID(connectionID)}) + if err != nil { + t.Fatal(err) + } + if !store.updatedWithOperation || updated.ConfiguredRef != "release" || operation == nil { + t.Fatalf("unexpected update result: %#v %#v", updated, operation) + } + if operation.ActorUserID != actor.UserID || operation.ProviderConnectionID == nil || *operation.ProviderConnectionID != connectionID || operation.CredentialVersion != 7 || operation.RepositoryVersion != 7 { + t.Fatalf("operation did not freeze actor credential: %#v", operation) + } + if operation.Kind != "update" || operation.RequestedRemoteURL != "https://github.com/example/new-repo.git" || operation.RequestedRef != "release" || operation.PreviousRemoteURL != repository.RemoteURL || operation.PreviousRef != "main" { + t.Fatalf("operation did not freeze source transition: %#v", operation) + } +} + +func TestDeleteDoesNotLoadProviderCredential(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github"} + store := &repositoryStore{repository: repository} + providers := &providerConnections{} + service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil) + + operation, err := service.Delete(context.Background(), actor, repository.WorkspaceID, repository.ID) + if err != nil { + t.Fatal(err) + } + if !store.deletionRequested || providers.calls != 0 || operation.Kind != "purge" || operation.ProviderConnectionID != nil { + t.Fatalf("unexpected local deletion operation: %#v provider calls=%d", operation, providers.calls) + } +} + +func TestRefreshPublicRepositoryWithoutProviderConnection(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", RemoteURL: "https://github.com/example/public-repo.git", NormalizedURL: "github.com/example/public-repo", ConfiguredRef: "main", ConfigVersion: 2, Status: "ready"} + store := &repositoryStore{repository: repository} + providers := &providerConnections{} + service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil) + + operation, err := service.Refresh(context.Background(), actor, repository.WorkspaceID, repository.ID, nil) + if err != nil { + t.Fatal(err) + } + if providers.calls != 0 || operation.ProviderConnectionID != nil || operation.Kind != "refresh" { + t.Fatalf("unexpected anonymous refresh: operation=%#v calls=%d", operation, providers.calls) + } +} + +func TestDeleteRejectsRepositoryWithActiveOperation(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Status: "syncing"} + store := &repositoryStore{repository: repository} + service := NewService(store, repositoryAuthorizer{}, &providerConnections{}, repositoryCanceller{}, nil) + + if _, err := service.Delete(context.Background(), actor, repository.WorkspaceID, repository.ID); err == nil { + t.Fatal("expected deletion to reject a repository with an active operation") + } + if store.deletionRequested { + t.Fatal("deletion was persisted while a repository operation was active") + } +} + +func TestUpdateSourceRejectsInactiveProviderConnection(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Name: "repo", RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo", ConfiguredRef: "main", ConfigVersion: 1, Status: "ready"} + store := &repositoryStore{repository: repository} + providers := &providerConnections{connection: identity.ProviderConnection{ID: uuid.New(), ProviderType: "github", BaseURL: "https://github.com", Status: "invalid"}} + service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil) + + if _, _, err := service.Update(context.Background(), actor, repository.WorkspaceID, repository.ID, UpdateInput{Ref: "release", ProviderConnectionID: repositoryConnectionID(providers.connection.ID)}); err == nil { + t.Fatal("expected update to reject an inactive provider connection") + } + if store.updatedWithOperation { + t.Fatal("source update was persisted with an inactive provider connection") + } +} + +func TestCancelRejectsRepositoryOutsideWorkspace(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New()} + operation := Operation{ID: uuid.New(), RepositoryID: repository.ID} + want := errors.New("repository not found") + store := &repositoryStore{repository: repository, operation: operation, getErr: want} + service := NewService(store, repositoryAuthorizer{}, &providerConnections{}, repositoryCanceller{}, nil) + + err := service.Cancel(context.Background(), actor, uuid.New(), repository.ID, operation.ID) + if !errors.Is(err, want) { + t.Fatalf("Cancel() error = %v, want %v", err, want) + } + if store.cancelled { + t.Fatal("operation from a repository outside the workspace was cancelled") + } +} + +func TestCancelUsesWorkflowIDReturnedByAtomicCancellation(t *testing.T) { + actor := auth.Actor{UserID: uuid.New()} + repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), Status: "syncing"} + operation := Operation{ID: uuid.New(), RepositoryID: repository.ID, WorkflowRunID: "current-hatchet-run"} + store := &repositoryStore{repository: repository, operation: operation} + var cancelled string + service := NewService(store, repositoryAuthorizer{}, &providerConnections{}, repositoryCanceller{called: &cancelled}, nil) + + if err := service.Cancel(context.Background(), actor, repository.WorkspaceID, repository.ID, operation.ID); err != nil { + t.Fatal(err) + } + if cancelled != operation.WorkflowRunID { + t.Fatalf("cancelled workflow = %q, want %q", cancelled, operation.WorkflowRunID) + } +} diff --git a/internal/repository/biz/store.go b/internal/repository/biz/store.go new file mode 100644 index 0000000..349b3ef --- /dev/null +++ b/internal/repository/biz/store.go @@ -0,0 +1,36 @@ +package biz + +import ( + "context" + + "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" +) + +type Store interface { + Create(context.Context, Repository, Operation) (Repository, Operation, error) + Get(context.Context, uuid.UUID, uuid.UUID) (Repository, error) + List(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Repository], error) + Update(context.Context, uuid.UUID, uuid.UUID, string) (Repository, error) + UpdateWithOperation(context.Context, uuid.UUID, uuid.UUID, string, Operation) (Repository, Operation, error) + Archive(context.Context, uuid.UUID, uuid.UUID, bool) (Repository, error) + RequestDeletion(context.Context, uuid.UUID, uuid.UUID, Operation) (Repository, Operation, error) + Delete(context.Context, uuid.UUID, uuid.UUID) error + CreateOperation(context.Context, uuid.UUID, Operation) (Operation, error) + ListOperations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Operation], error) + Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (Snapshot, error) + Snapshots(context.Context, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[Snapshot], error) + GetWorkItem(context.Context, uuid.UUID) (WorkItem, error) + StartOperation(context.Context, uuid.UUID) (Operation, error) + CompleteSync(context.Context, WorkItem, uuid.UUID, gitrepo.Snapshot) (Operation, error) + HasRetainedAnalysis(context.Context, uuid.UUID) (bool, error) + CompleteRetainedDeletion(context.Context, WorkItem) error + CompletePurge(context.Context, WorkItem) error + FailOperation(context.Context, WorkItem, Failure) error + CancelOperation(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) (Operation, bool, error) +} + +type Canceller interface { + CancelRepository(context.Context, string) error +} diff --git a/internal/repository/channel.go b/internal/repository/channel.go deleted file mode 100644 index 1380733..0000000 --- a/internal/repository/channel.go +++ /dev/null @@ -1,41 +0,0 @@ -package repository - -import ( - "context" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" - channelcore "github.com/mooncode-ai/mooncode/pkg/channel" -) - -type MessageCursor = TimeCursor -type ChannelMutationStore interface { - CreateChannel(ctx context.Context, instance model.ChannelInstance) (model.ChannelInstance, error) - UpdateChannel(ctx context.Context, instance model.ChannelInstance, expectedVersion int64) (model.ChannelInstance, error) - SetChannelEnabled(ctx context.Context, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error) - SoftDeleteChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error) - AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error - AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error -} -type ChannelStore interface { - MutationStore - WithinChannelTx(ctx context.Context, fn func(ChannelMutationStore) error) error - CreateChannel(ctx context.Context, instance model.ChannelInstance) (model.ChannelInstance, error) - ListChannels(ctx context.Context, workspaceID uuid.UUID) ([]model.ChannelInstance, error) - GetChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error) - UpdateChannel(ctx context.Context, instance model.ChannelInstance, expectedVersion int64) (model.ChannelInstance, error) - SetChannelEnabled(ctx context.Context, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error) - SoftDeleteChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error) - ListEnabledChannels(ctx context.Context) ([]model.ChannelInstance, error) - AcquireChannelLease(ctx context.Context, instanceID uuid.UUID, owner string, until time.Time) (model.ChannelLease, error) - RenewChannelLease(ctx context.Context, lease model.ChannelLease, until time.Time) error - ReleaseChannelLease(ctx context.Context, lease model.ChannelLease) error - SetChannelStatus(ctx context.Context, status model.ChannelRuntimeStatus) error - GetChannelStatus(ctx context.Context, instanceID uuid.UUID) (model.ChannelRuntimeStatus, error) - AcceptInbound(ctx context.Context, instance model.ChannelInstance, message channelcore.InboundMessage) (bool, error) - ListMessages(ctx context.Context, workspaceID uuid.UUID, channelID, conversationID *uuid.UUID, cursor MessageCursor, pageSize int32) ([]model.IMMessage, error) - ListConversations(ctx context.Context, workspaceID uuid.UUID, pageSize int32) ([]model.IMConversation, error) - AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error - AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error -} diff --git a/internal/repository/data/options.go b/internal/repository/data/options.go new file mode 100644 index 0000000..598f7d6 --- /dev/null +++ b/internal/repository/data/options.go @@ -0,0 +1,11 @@ +package data + +type Option func(*Store) + +func WithMaxRepositoriesPerWorkspace(limit int64) Option { + return func(store *Store) { + if limit > 0 { + store.maxPerWorkspace = limit + } + } +} diff --git a/internal/repository/data/quota_integration_test.go b/internal/repository/data/quota_integration_test.go new file mode 100644 index 0000000..b8e5b0b --- /dev/null +++ b/internal/repository/data/quota_integration_test.go @@ -0,0 +1,65 @@ +//go:build integration + +package data + +import ( + "context" + "os" + "testing" + + "github.com/fuchencong/mooncode/internal/platform/fault" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" +) + +func TestRepositoryWorkspaceQuotaIsHardLimit(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + manager, err := gitrepo.NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + store := NewStore(pool, manager, WithMaxRepositoriesPerWorkspace(1)) + userID, workspaceID := uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Repository quota',$2,$3)`, workspaceID, "repository-quota-"+workspaceID.String(), userID); err != nil { + t.Fatal(err) + } + + create := func(name string) error { + repositoryID := uuid.New() + item := repository.Repository{ + ID: repositoryID, WorkspaceID: workspaceID, ProviderType: "github", Name: name, + RemoteURL: "https://github.com/example/" + name + ".git", NormalizedURL: "github.com/example/" + name, + ConfiguredRef: "main", Status: "provisioning", + } + operation := repository.Operation{ + ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: userID, + RepositoryVersion: 1, Kind: "provision", + RequestedProviderType: "github", RequestedRemoteURL: item.RemoteURL, + RequestedNormalizedURL: item.NormalizedURL, RequestedRef: "main", + PreviousProviderType: "github", PreviousRemoteURL: item.RemoteURL, + PreviousNormalizedURL: item.NormalizedURL, PreviousRef: "main", + Status: "queued", + } + _, _, err := store.Create(ctx, item, operation) + + return err + } + if err := create("first"); err != nil { + t.Fatal(err) + } + if err := create("second"); err == nil { + t.Fatal("expected repository quota error") + } else if problem, ok := fault.From(err); !ok || problem.Code() != "repository.workspace_quota_exceeded" { + t.Fatalf("quota error = %v", err) + } +} diff --git a/internal/repository/data/store.go b/internal/repository/data/store.go new file mode 100644 index 0000000..37a25a9 --- /dev/null +++ b/internal/repository/data/store.go @@ -0,0 +1,639 @@ +package data + +import ( + "context" + "errors" + "time" + + "github.com/fuchencong/mooncode/internal/data/pagecursor" + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/platform/pagination" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + workflow "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool + queries *sqlc.Queries + git gitrepo.Manager + maxPerWorkspace int64 +} + +func NewStore(pool *pgxpool.Pool, git gitrepo.Manager, options ...Option) *Store { + store := &Store{pool: pool, queries: sqlc.New(pool), git: git, maxPerWorkspace: 100} + for _, option := range options { + option(store) + } + + return store +} + +func (s *Store) Create(ctx context.Context, item repository.Repository, operation repository.Operation) (repository.Repository, repository.Operation, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + if _, err = q.LockWorkspaceQuota(ctx, item.WorkspaceID); err != nil { + return repository.Repository{}, repository.Operation{}, err + } + count, err := q.CountManagedRepositories(ctx, item.WorkspaceID) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + if count >= s.maxPerWorkspace { + return repository.Repository{}, repository.Operation{}, fault.New(fault.Conflict, "repository.workspace_quota_exceeded", "Workspace repository quota has been reached") + } + row, err := q.CreateRepository(ctx, sqlc.CreateRepositoryParams{ID: item.ID, WorkspaceID: item.WorkspaceID, ProviderType: item.ProviderType, Name: item.Name, RemoteUrl: item.RemoteURL, NormalizedUrl: item.NormalizedURL, ConfiguredRef: item.ConfiguredRef, GitPath: s.git.Path(item.ID), CreatedBy: operation.ActorUserID}) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + if _, err = q.ReserveRepositorySource(ctx, sqlc.ReserveRepositorySourceParams{ + WorkspaceID: item.WorkspaceID, NormalizedUrl: item.NormalizedURL, RepositoryID: item.ID, + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.Repository{}, repository.Operation{}, sourceConflict() + } + + return repository.Repository{}, repository.Operation{}, err + } + op, err := q.CreateRepositoryOperation(ctx, operationParams(operation)) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + dispatch, err := repositoryDispatch(operation.ID, item.ID) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + if _, err = q.CreateWorkflowDispatch(ctx, dispatchParams(dispatch)); err != nil { + return repository.Repository{}, repository.Operation{}, err + } + if err = recordRepositoryOperation(ctx, q, item.WorkspaceID, operation, audit.ActionRepositoryProvisionRequested); err != nil { + return repository.Repository{}, repository.Operation{}, err + } + err = tx.Commit(ctx) + + return mapRepository(row), mapOperation(op), err +} + +func (s *Store) Get(ctx context.Context, workspaceID, id uuid.UUID) (repository.Repository, error) { + row, err := s.queries.GetRepository(ctx, sqlc.GetRepositoryParams{ID: id, WorkspaceID: workspaceID}) + if err != nil { + return repository.Repository{}, err + } + item := mapRepository(row) + if row.CurrentSnapshotID.Valid { + snapshot, snapshotErr := s.queries.GetSnapshot(ctx, sqlc.GetSnapshotParams{ID: row.CurrentSnapshotID.UUID, RepositoryID: id}) + if snapshotErr != nil { + return repository.Repository{}, snapshotErr + } + mapped := mapSnapshot(snapshot) + item.CurrentSnapshot = &mapped + } + + return item, nil +} + +func (s *Store) List(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[repository.Repository], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListRepositories(ctx, sqlc.ListRepositoriesParams{ + WorkspaceID: workspaceID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[repository.Repository]{}, err + } + items := make([]repository.Repository, 0, len(rows)) + for _, row := range rows { + item := repository.Repository{ + ID: row.ID, WorkspaceID: row.WorkspaceID, ProviderType: row.ProviderType, Name: row.Name, + RemoteURL: row.RemoteUrl, NormalizedURL: row.NormalizedUrl, ConfiguredRef: row.ConfiguredRef, + ConfigVersion: row.ConfigVersion, Status: row.Status, MirrorSizeBytes: row.MirrorSizeBytes, + LastSyncAt: optionalTime(row.LastSyncAt), LastErrorCode: row.LastErrorCode.String, LastErrorMessage: row.LastErrorMessage.String, + ArchivedAt: optionalTime(row.ArchivedAt), DeletedAt: optionalTime(row.DeletedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, + } + if row.CurrentSnapshotID.Valid { + id := row.CurrentSnapshotID.UUID + item.CurrentSnapshotID = &id + snapshot := repository.Snapshot{ID: id, RepositoryID: row.ID, CommitSHA: row.CurrentCommitSha.String, SourceRef: row.CurrentSourceRef.String, AuthorName: row.CurrentAuthorName.String, AuthoredAt: optionalTime(row.CurrentAuthoredAt), Title: row.CurrentTitle.String, SourceState: row.CurrentSourceState.String, CreatedAt: row.SnapshotCreatedAt.Time} + item.CurrentSnapshot = &snapshot + } + items = append(items, item) + } + + return pagination.Build(items, page.Limit, func(item repository.Repository) (time.Time, uuid.UUID) { + return item.CreatedAt, item.ID + }), nil +} + +func (s *Store) Update(ctx context.Context, workspaceID, id uuid.UUID, name string) (repository.Repository, error) { + row, err := s.queries.UpdateRepository(ctx, sqlc.UpdateRepositoryParams{ID: id, WorkspaceID: workspaceID, Name: name}) + return mapRepository(row), err +} + +func (s *Store) UpdateWithOperation(ctx context.Context, workspaceID, id uuid.UUID, name string, operation repository.Operation) (repository.Repository, repository.Operation, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + if _, err = q.ReserveRepositorySource(ctx, sqlc.ReserveRepositorySourceParams{ + WorkspaceID: workspaceID, NormalizedUrl: operation.RequestedNormalizedURL, RepositoryID: id, + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.Repository{}, repository.Operation{}, sourceConflict() + } + + return repository.Repository{}, repository.Operation{}, err + } + row, err := q.StageRepositoryUpdate(ctx, sqlc.StageRepositoryUpdateParams{ + ID: id, WorkspaceID: workspaceID, Name: name, + ProviderType: operation.RequestedProviderType, RemoteUrl: operation.RequestedRemoteURL, + NormalizedUrl: operation.RequestedNormalizedURL, ConfiguredRef: operation.RequestedRef, + ConfigVersion: operation.RepositoryVersion - 1, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.Repository{}, repository.Operation{}, stateConflict() + } + + return repository.Repository{}, repository.Operation{}, err + } + op, err := q.CreateRepositoryOperation(ctx, operationParams(operation)) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + dispatch, err := repositoryDispatch(operation.ID, operation.RepositoryID) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + if _, err = q.CreateWorkflowDispatch(ctx, dispatchParams(dispatch)); err != nil { + return repository.Repository{}, repository.Operation{}, err + } + if err = recordRepositoryOperation(ctx, q, workspaceID, operation, audit.ActionRepositorySourceUpdateRequested); err != nil { + return repository.Repository{}, repository.Operation{}, err + } + if err = tx.Commit(ctx); err != nil { + return repository.Repository{}, repository.Operation{}, err + } + + return mapRepository(row), mapOperation(op), nil +} + +func (s *Store) Archive(ctx context.Context, workspaceID, id uuid.UUID, archived bool) (repository.Repository, error) { + var row sqlc.Repository + var err error + if archived { + row, err = s.queries.ArchiveRepository(ctx, sqlc.ArchiveRepositoryParams{ID: id, WorkspaceID: workspaceID}) + } else { + row, err = s.queries.RestoreRepository(ctx, sqlc.RestoreRepositoryParams{ID: id, WorkspaceID: workspaceID}) + } + + return mapRepository(row), err +} + +func (s *Store) RequestDeletion(ctx context.Context, workspaceID, id uuid.UUID, operation repository.Operation) (repository.Repository, repository.Operation, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + row, err := q.RequestRepositoryDeletion(ctx, sqlc.RequestRepositoryDeletionParams{ID: id, WorkspaceID: workspaceID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.Repository{}, repository.Operation{}, stateConflict() + } + + return repository.Repository{}, repository.Operation{}, err + } + op, err := q.CreateRepositoryOperation(ctx, operationParams(operation)) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + dispatch, err := repositoryDispatch(operation.ID, operation.RepositoryID) + if err != nil { + return repository.Repository{}, repository.Operation{}, err + } + if _, err = q.CreateWorkflowDispatch(ctx, dispatchParams(dispatch)); err != nil { + return repository.Repository{}, repository.Operation{}, err + } + if err = recordRepositoryOperation(ctx, q, workspaceID, operation, audit.ActionRepositoryDeletionRequested); err != nil { + return repository.Repository{}, repository.Operation{}, err + } + err = tx.Commit(ctx) + + return mapRepository(row), mapOperation(op), err +} + +func (s *Store) Delete(ctx context.Context, workspaceID, id uuid.UUID) error { + count, err := s.queries.DeleteRepository(ctx, sqlc.DeleteRepositoryParams{ID: id, WorkspaceID: workspaceID}) + if err == nil && count == 0 { + return pgx.ErrNoRows + } + return err +} + +func (s *Store) CreateOperation(ctx context.Context, workspaceID uuid.UUID, operation repository.Operation) (repository.Operation, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return repository.Operation{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + row, err := q.CreateRepositoryOperation(ctx, operationParams(operation)) + if err != nil { + return repository.Operation{}, err + } + if operation.Kind != "purge" { + count, err := q.SetRepositorySyncing(ctx, sqlc.SetRepositorySyncingParams{ID: operation.RepositoryID, ConfigVersion: operation.RepositoryVersion}) + if err != nil { + return repository.Operation{}, err + } + if count != 1 { + return repository.Operation{}, stateConflict() + } + } + dispatch, err := repositoryDispatch(operation.ID, operation.RepositoryID) + if err != nil { + return repository.Operation{}, err + } + if _, err = q.CreateWorkflowDispatch(ctx, dispatchParams(dispatch)); err != nil { + return repository.Operation{}, err + } + if err = recordRepositoryOperation(ctx, q, workspaceID, operation, audit.ActionRepositoryRefreshRequested); err != nil { + return repository.Operation{}, err + } + if err = tx.Commit(ctx); err != nil { + return repository.Operation{}, err + } + + return mapOperation(row), nil +} + +func (s *Store) ListOperations(ctx context.Context, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[repository.Operation], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListRepositoryOperations(ctx, sqlc.ListRepositoryOperationsParams{ + RepositoryID: repositoryID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[repository.Operation]{}, err + } + items := make([]repository.Operation, 0, len(rows)) + for _, row := range rows { + items = append(items, mapOperation(row)) + } + + return pagination.Build(items, page.Limit, func(item repository.Operation) (time.Time, uuid.UUID) { + return item.CreatedAt, item.ID + }), nil +} + +func (s *Store) Snapshot(ctx context.Context, workspaceID, repositoryID, snapshotID uuid.UUID) (repository.Snapshot, error) { + row, err := s.queries.GetWorkspaceSnapshot(ctx, sqlc.GetWorkspaceSnapshotParams{ID: snapshotID, RepositoryID: repositoryID, WorkspaceID: workspaceID}) + + return mapSnapshot(row), err +} + +func (s *Store) Snapshots(ctx context.Context, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[repository.Snapshot], error) { + cursorTime, cursorID := pagecursor.SQL(page) + rows, err := s.queries.ListRepositorySnapshots(ctx, sqlc.ListRepositorySnapshotsParams{ + RepositoryID: repositoryID, + WorkspaceID: workspaceID, + CursorTime: cursorTime, + CursorID: cursorID, + Limit: page.QueryLimit(), + }) + if err != nil { + return pagination.Page[repository.Snapshot]{}, err + } + items := make([]repository.Snapshot, 0, len(rows)) + for _, row := range rows { + items = append(items, mapSnapshot(row)) + } + + return pagination.Build(items, page.Limit, func(item repository.Snapshot) (time.Time, uuid.UUID) { + return item.CreatedAt, item.ID + }), nil +} + +func (s *Store) GetWorkItem(ctx context.Context, operationID uuid.UUID) (repository.WorkItem, error) { + op, err := s.queries.GetRepositoryOperation(ctx, operationID) + if err != nil { + return repository.WorkItem{}, err + } + var row sqlc.Repository + err = s.pool.QueryRow(ctx, `SELECT id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,config_version,git_path,status,current_snapshot_id,mirror_size_bytes,last_sync_at,last_error_code,last_error_message,archived_at,deleted_at,created_by,created_at,updated_at FROM repositories WHERE id=$1`, op.RepositoryID).Scan( + &row.ID, &row.WorkspaceID, &row.ProviderType, &row.Name, &row.RemoteUrl, &row.NormalizedUrl, &row.ConfiguredRef, &row.ConfigVersion, &row.GitPath, &row.Status, &row.CurrentSnapshotID, &row.MirrorSizeBytes, &row.LastSyncAt, &row.LastErrorCode, &row.LastErrorMessage, &row.ArchivedAt, &row.DeletedAt, &row.CreatedBy, &row.CreatedAt, &row.UpdatedAt, + ) + if err != nil { + return repository.WorkItem{}, err + } + item := mapRepository(row) + if row.CurrentSnapshotID.Valid { + snapshot, snapshotErr := s.queries.GetSnapshot(ctx, sqlc.GetSnapshotParams{ID: row.CurrentSnapshotID.UUID, RepositoryID: row.ID}) + if snapshotErr != nil { + return repository.WorkItem{}, snapshotErr + } + mapped := mapSnapshot(snapshot) + item.CurrentSnapshot = &mapped + } + + return repository.WorkItem{Repository: item, Operation: mapOperation(op)}, nil +} + +func (s *Store) StartOperation(ctx context.Context, id uuid.UUID) (repository.Operation, error) { + row, err := s.queries.StartRepositoryOperation(ctx, id) + return mapOperation(row), err +} + +func (s *Store) CompleteSync(ctx context.Context, work repository.WorkItem, snapshotID uuid.UUID, result gitrepo.Snapshot) (repository.Operation, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return repository.Operation{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + snapshot, err := q.EnsureSnapshot(ctx, sqlc.EnsureSnapshotParams{ID: snapshotID, RepositoryID: work.Repository.ID, CommitSha: result.CommitSHA, SourceRef: work.Operation.RequestedRef, GitRef: "refs/mooncode/snapshots/" + snapshotID.String(), AuthorName: text(result.Author), AuthoredAt: pgtype.Timestamptz{Time: result.AuthoredAt, Valid: !result.AuthoredAt.IsZero()}, Title: text(result.Title)}) + if err != nil { + return repository.Operation{}, err + } + if _, err = q.SetRepositoryCurrentSnapshot(ctx, sqlc.SetRepositoryCurrentSnapshotParams{ + ID: work.Repository.ID, WorkspaceID: work.Repository.WorkspaceID, + CurrentSnapshotID: uuid.NullUUID{UUID: snapshot.ID, Valid: true}, + ProviderType: work.Operation.RequestedProviderType, RemoteUrl: work.Operation.RequestedRemoteURL, + NormalizedUrl: work.Operation.RequestedNormalizedURL, ConfiguredRef: work.Operation.RequestedRef, + ConfigVersion: work.Operation.RepositoryVersion, MirrorSizeBytes: result.MirrorSizeBytes, + }); err != nil { + return repository.Operation{}, err + } + if work.Operation.PreviousNormalizedURL != work.Operation.RequestedNormalizedURL { + if _, err = q.ReleaseRepositorySource(ctx, sqlc.ReleaseRepositorySourceParams{ + WorkspaceID: work.Repository.WorkspaceID, NormalizedUrl: work.Operation.PreviousNormalizedURL, + RepositoryID: work.Repository.ID, + }); err != nil { + return repository.Operation{}, err + } + } + outcome := "changed" + if work.Repository.CurrentSnapshot != nil && work.Repository.CurrentSnapshot.CommitSHA == result.CommitSHA { + outcome = "no_change" + } + op, err := q.FinishRepositoryOperation(ctx, sqlc.FinishRepositoryOperationParams{ID: work.Operation.ID, Outcome: text(outcome), ResolvedCommitSha: text(result.CommitSHA), SnapshotID: uuid.NullUUID{UUID: snapshot.ID, Valid: true}}) + if err == nil { + err = tx.Commit(ctx) + } + + return mapOperation(op), err +} + +func (s *Store) CompletePurge(ctx context.Context, work repository.WorkItem) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + if err = q.MarkSnapshotsPurged(ctx, work.Repository.ID); err != nil { + return err + } + count, err := q.DeleteRepository(ctx, sqlc.DeleteRepositoryParams{ID: work.Repository.ID, WorkspaceID: work.Repository.WorkspaceID}) + if err != nil || count != 1 { + return fault.New(fault.Conflict, "repository.deletion_conflict", "Repository could not be deleted") + } + + return tx.Commit(ctx) +} + +func (s *Store) HasRetainedAnalysis(ctx context.Context, repositoryID uuid.UUID) (bool, error) { + return s.queries.HasRepositoryAnalysisRuns(ctx, repositoryID) +} + +func (s *Store) CompleteRetainedDeletion(ctx context.Context, work repository.WorkItem) error { + count, err := s.queries.CompleteRepositoryLogicalDeletion(ctx, sqlc.CompleteRepositoryLogicalDeletionParams{ID: work.Operation.ID, RepositoryID: work.Repository.ID}) + if err != nil { + return err + } + if count != 1 { + return fault.New(fault.Conflict, "repository.deletion_conflict", "Repository deletion could not be completed") + } + + return nil +} + +func (s *Store) FailOperation(ctx context.Context, work repository.WorkItem, failure repository.Failure) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + if _, err := q.FailRepositoryOperation(ctx, sqlc.FailRepositoryOperationParams{ID: work.Operation.ID, ErrorMessage: text(failure.Message)}); errors.Is(err, pgx.ErrNoRows) { + return nil + } else if err != nil { + return err + } + if work.Operation.Kind == "purge" && work.Repository.DeletedAt != nil { + if _, err := q.RestoreDeletedRepositoryAfterPurgeFailure(ctx, sqlc.RestoreDeletedRepositoryAfterPurgeFailureParams{ + ID: work.Repository.ID, ConfigVersion: work.Operation.RepositoryVersion, + LastErrorCode: text(failure.Code), LastErrorMessage: text(failure.Message), + }); err != nil { + return err + } + } else { + if _, err := q.ProjectRepositoryOperationFailure(ctx, sqlc.ProjectRepositoryOperationFailureParams{ + ID: work.Repository.ID, ConfigVersion: work.Operation.RepositoryVersion, + PreviousProviderType: work.Operation.PreviousProviderType, PreviousRemoteUrl: work.Operation.PreviousRemoteURL, + PreviousNormalizedUrl: work.Operation.PreviousNormalizedURL, PreviousRef: work.Operation.PreviousRef, + LastErrorCode: text(failure.Code), LastErrorMessage: text(failure.Message), + }); err != nil { + return err + } + } + if work.Operation.RequestedNormalizedURL != work.Operation.PreviousNormalizedURL { + if _, err := q.ReleaseRepositorySource(ctx, sqlc.ReleaseRepositorySourceParams{ + WorkspaceID: work.Repository.WorkspaceID, NormalizedUrl: work.Operation.RequestedNormalizedURL, + RepositoryID: work.Repository.ID, + }); err != nil { + return err + } + } + + return tx.Commit(ctx) +} + +func (s *Store) CancelOperation(ctx context.Context, workspaceID, actorID, repositoryID, operationID uuid.UUID) (repository.Operation, bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return repository.Operation{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + operation, err := q.GetRepositoryOperation(ctx, operationID) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && operation.RepositoryID != repositoryID) { + return repository.Operation{}, false, nil + } + if err != nil { + return repository.Operation{}, false, err + } + cancelled, err := q.CancelRepositoryOperation(ctx, sqlc.CancelRepositoryOperationParams{ID: operationID, RepositoryID: repositoryID}) + if errors.Is(err, pgx.ErrNoRows) { + return repository.Operation{}, false, nil + } + if err != nil { + return repository.Operation{}, false, err + } + if err = q.CancelWorkflowDispatch(ctx, sqlc.CancelWorkflowDispatchParams{AggregateType: workflow.AggregateRepositoryOperation, AggregateID: operationID}); err != nil { + return repository.Operation{}, false, err + } + if _, err = q.RestoreRepositoryAfterOperation(ctx, sqlc.RestoreRepositoryAfterOperationParams{ + ID: repositoryID, ConfigVersion: operation.RepositoryVersion, + PreviousProviderType: operation.PreviousProviderType, PreviousRemoteUrl: operation.PreviousRemoteUrl, + PreviousNormalizedUrl: operation.PreviousNormalizedUrl, PreviousRef: operation.PreviousRef, + }); err != nil { + return repository.Operation{}, false, err + } + if operation.RequestedNormalizedUrl != operation.PreviousNormalizedUrl { + if _, err = q.ReleaseRepositorySource(ctx, sqlc.ReleaseRepositorySourceParams{ + WorkspaceID: workspaceID, NormalizedUrl: operation.RequestedNormalizedUrl, + RepositoryID: repositoryID, + }); err != nil { + return repository.Operation{}, false, err + } + } + if err = audit.Record(ctx, q, audit.Event{ + WorkspaceID: workspaceID, + ActorUserID: actorID, + Action: audit.ActionRepositoryOperationCancelled, + Resource: audit.ResourceRepository, + ResourceID: repositoryID, + Metadata: map[string]any{"operationId": operationID}, + }); err != nil { + return repository.Operation{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return repository.Operation{}, false, err + } + + return mapOperation(cancelled), true, nil +} + +func recordRepositoryOperation(ctx context.Context, writer audit.Writer, workspaceID uuid.UUID, operation repository.Operation, action string) error { + return audit.Record(ctx, writer, audit.Event{ + WorkspaceID: workspaceID, + ActorUserID: operation.ActorUserID, + Action: action, + Resource: audit.ResourceRepository, + ResourceID: operation.RepositoryID, + Metadata: map[string]any{"operationId": operation.ID}, + }) +} + +func repositoryDispatch(operationID, repositoryID uuid.UUID) (workflow.Dispatch, error) { + return workflow.NewDispatch( + workflow.AggregateRepositoryOperation, + operationID, + workflow.WorkflowRepositoryOperation, + workflow.Payload{AggregateID: operationID, RepositoryID: repositoryID}, + ) +} + +func dispatchParams(dispatch workflow.Dispatch) sqlc.CreateWorkflowDispatchParams { + return sqlc.CreateWorkflowDispatchParams{ + ID: dispatch.ID, + AggregateType: dispatch.AggregateType, + AggregateID: dispatch.AggregateID, + WorkflowName: dispatch.WorkflowName, + Payload: dispatch.Payload, + } +} + +func operationParams(operation repository.Operation) sqlc.CreateRepositoryOperationParams { + params := sqlc.CreateRepositoryOperationParams{ + ID: operation.ID, RepositoryID: operation.RepositoryID, ActorUserID: operation.ActorUserID, + RepositoryVersion: operation.RepositoryVersion, Kind: operation.Kind, + RequestedProviderType: operation.RequestedProviderType, RequestedRemoteUrl: operation.RequestedRemoteURL, + RequestedNormalizedUrl: operation.RequestedNormalizedURL, RequestedRef: operation.RequestedRef, + PreviousProviderType: operation.PreviousProviderType, PreviousRemoteUrl: operation.PreviousRemoteURL, + PreviousNormalizedUrl: operation.PreviousNormalizedURL, PreviousRef: operation.PreviousRef, + } + if operation.ProviderConnectionID != nil { + params.ProviderConnectionID = uuid.NullUUID{UUID: *operation.ProviderConnectionID, Valid: true} + params.CredentialVersion = pgtype.Int8{Int64: operation.CredentialVersion, Valid: true} + } + return params +} + +func mapRepository(row sqlc.Repository) repository.Repository { + item := repository.Repository{ + ID: row.ID, WorkspaceID: row.WorkspaceID, ProviderType: row.ProviderType, Name: row.Name, + RemoteURL: row.RemoteUrl, NormalizedURL: row.NormalizedUrl, ConfiguredRef: row.ConfiguredRef, + ConfigVersion: row.ConfigVersion, Status: row.Status, MirrorSizeBytes: row.MirrorSizeBytes, + LastSyncAt: optionalTime(row.LastSyncAt), LastErrorCode: row.LastErrorCode.String, LastErrorMessage: row.LastErrorMessage.String, + ArchivedAt: optionalTime(row.ArchivedAt), DeletedAt: optionalTime(row.DeletedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, + } + if row.CurrentSnapshotID.Valid { + id := row.CurrentSnapshotID.UUID + item.CurrentSnapshotID = &id + } + return item +} + +func mapSnapshot(row sqlc.CommitSnapshot) repository.Snapshot { + return repository.Snapshot{ID: row.ID, RepositoryID: row.RepositoryID, CommitSHA: row.CommitSha, SourceRef: row.SourceRef, AuthorName: row.AuthorName.String, AuthoredAt: optionalTime(row.AuthoredAt), Title: row.Title.String, SourceState: row.SourceState, CreatedAt: row.CreatedAt.Time} +} + +func mapOperation(row sqlc.RepositoryOperation) repository.Operation { + item := repository.Operation{ + ID: row.ID, RepositoryID: row.RepositoryID, ActorUserID: row.ActorUserID, + CredentialVersion: row.CredentialVersion.Int64, RepositoryVersion: row.RepositoryVersion, Kind: row.Kind, + RequestedProviderType: row.RequestedProviderType, RequestedRemoteURL: row.RequestedRemoteUrl, + RequestedNormalizedURL: row.RequestedNormalizedUrl, RequestedRef: row.RequestedRef, + PreviousProviderType: row.PreviousProviderType, PreviousRemoteURL: row.PreviousRemoteUrl, + PreviousNormalizedURL: row.PreviousNormalizedUrl, PreviousRef: row.PreviousRef, + Status: row.Status, Outcome: row.Outcome.String, ResolvedCommitSHA: row.ResolvedCommitSha.String, + WorkflowRunID: row.WorkflowRunID.String, ErrorMessage: row.ErrorMessage.String, + CreatedAt: row.CreatedAt.Time, StartedAt: optionalTime(row.StartedAt), FinishedAt: optionalTime(row.FinishedAt), + } + if row.ProviderConnectionID.Valid { + id := row.ProviderConnectionID.UUID + item.ProviderConnectionID = &id + } + if row.SnapshotID.Valid { + id := row.SnapshotID.UUID + item.SnapshotID = &id + } + return item +} + +func text(value string) pgtype.Text { return pgtype.Text{String: value, Valid: value != ""} } + +func sourceConflict() error { + return fault.New(fault.Conflict, "repository.source_conflict", "Repository source is already managed by this workspace") +} + +func stateConflict() error { + return fault.New(fault.Conflict, "repository.state_conflict", "Repository state changed while the operation was requested") +} + +func optionalTime(value pgtype.Timestamptz) *time.Time { + if !value.Valid { + return nil + } + result := value.Time + return &result +} diff --git a/internal/repository/data/store_integration_test.go b/internal/repository/data/store_integration_test.go new file mode 100644 index 0000000..b1fd0da --- /dev/null +++ b/internal/repository/data/store_integration_test.go @@ -0,0 +1,453 @@ +//go:build integration + +package data + +import ( + "context" + "os" + "testing" + + "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/platform/pagination" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestRepositoryOperationProjectsSyncLifecycle(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + manager, err := gitrepo.NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + store := NewStore(pool, manager) + + userID, workspaceID, repositoryID := uuid.New(), uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Repository projection',$2,$3)`, workspaceID, "repository-projection-"+workspaceID.String(), userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) +VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, repositoryID, workspaceID, "github.com/example/"+repositoryID.String(), manager.Path(repositoryID), userID); err != nil { + t.Fatal(err) + } + + newOperation := func() repository.Operation { + return repository.Operation{ + ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: userID, + RepositoryVersion: 1, Kind: "refresh", + RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/repo.git", + RequestedNormalizedURL: "github.com/example/" + repositoryID.String(), RequestedRef: "main", + PreviousProviderType: "github", PreviousRemoteURL: "https://github.com/example/repo.git", + PreviousNormalizedURL: "github.com/example/" + repositoryID.String(), PreviousRef: "main", + Status: "queued", + } + } + + first := newOperation() + if _, err := store.CreateOperation(ctx, workspaceID, first); err != nil { + t.Fatal(err) + } + assertRepositoryProjection(t, pool, repositoryID, "syncing", 0, "") + work, err := store.GetWorkItem(ctx, first.ID) + if err != nil { + t.Fatal(err) + } + if _, err := store.StartOperation(ctx, first.ID); err != nil { + t.Fatal(err) + } + if _, err := store.CompleteSync(ctx, work, uuid.New(), gitrepo.Snapshot{CommitSHA: "1111111111111111111111111111111111111111", MirrorSizeBytes: 1234}); err != nil { + t.Fatal(err) + } + item, err := store.Get(ctx, workspaceID, repositoryID) + if err != nil { + t.Fatal(err) + } + if item.Status != "ready" || item.MirrorSizeBytes != 1234 || item.LastSyncAt == nil || item.CurrentSnapshot == nil || item.LastErrorCode != "" { + t.Fatalf("successful sync projection = %+v", item) + } + + failed := newOperation() + if _, err := store.CreateOperation(ctx, workspaceID, failed); err != nil { + t.Fatal(err) + } + failedWork, err := store.GetWorkItem(ctx, failed.ID) + if err != nil { + t.Fatal(err) + } + if _, err := store.StartOperation(ctx, failed.ID); err != nil { + t.Fatal(err) + } + if err := store.FailOperation(ctx, failedWork, repository.Failure{Code: "repository.sync_failed", Message: "Repository synchronization failed"}); err != nil { + t.Fatal(err) + } + assertRepositoryProjection(t, pool, repositoryID, "ready", 1234, "repository.sync_failed") + + cancelled := newOperation() + if _, err := store.CreateOperation(ctx, workspaceID, cancelled); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE repository_operations SET workflow_run_id='repository-workflow-run' WHERE id=$1`, cancelled.ID); err != nil { + t.Fatal(err) + } + cancelledOperation, ok, err := store.CancelOperation(ctx, workspaceID, userID, repositoryID, cancelled.ID) + if err != nil || !ok { + t.Fatalf("cancel operation = (%v, %v)", ok, err) + } + if cancelledOperation.WorkflowRunID != "repository-workflow-run" { + t.Fatalf("cancel operation workflow run ID = %q", cancelledOperation.WorkflowRunID) + } + assertRepositoryProjection(t, pool, repositoryID, "ready", 1234, "repository.sync_failed") + + stale := newOperation() + if _, err := store.CreateOperation(ctx, workspaceID, stale); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE repositories SET config_version=2,status='ready',last_error_code=NULL,last_error_message=NULL WHERE id=$1`, repositoryID); err != nil { + t.Fatal(err) + } + staleWork, err := store.GetWorkItem(ctx, stale.ID) + if err != nil { + t.Fatal(err) + } + if _, err := store.StartOperation(ctx, stale.ID); err != nil { + t.Fatal(err) + } + if err := store.FailOperation(ctx, staleWork, repository.Failure{Code: "repository.sync_failed", Message: "Repository synchronization failed"}); err != nil { + t.Fatal(err) + } + assertRepositoryProjection(t, pool, repositoryID, "ready", 1234, "") + assertRepositoryAudit(t, pool, workspaceID, userID, repositoryID) + assertOperationPagination(t, store, repositoryID) +} + +func TestRepositorySyncReusesSnapshotForUnchangedCommit(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + manager, err := gitrepo.NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + store := NewStore(pool, manager) + + userID, workspaceID, repositoryID, snapshotID := uuid.New(), uuid.New(), uuid.New(), uuid.New() + commitSHA := "1111111111111111111111111111111111111111" + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Snapshot reuse',$2,$3)`, workspaceID, "snapshot-reuse-"+workspaceID.String(), userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) +VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, repositoryID, workspaceID, "github.com/example/"+repositoryID.String(), manager.Path(repositoryID), userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) +VALUES ($1,$2,$3,'main',$4,'available')`, snapshotID, repositoryID, commitSHA, "refs/mooncode/snapshots/"+snapshotID.String()); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE repositories SET current_snapshot_id=$2 WHERE id=$1`, repositoryID, snapshotID); err != nil { + t.Fatal(err) + } + + operation := repository.Operation{ + ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: userID, + RepositoryVersion: 1, Kind: "refresh", + RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/repo.git", + RequestedNormalizedURL: "github.com/example/" + repositoryID.String(), RequestedRef: "main", + PreviousProviderType: "github", PreviousRemoteURL: "https://github.com/example/repo.git", + PreviousNormalizedURL: "github.com/example/" + repositoryID.String(), PreviousRef: "main", + Status: "queued", + } + if _, err := store.CreateOperation(ctx, workspaceID, operation); err != nil { + t.Fatal(err) + } + work, err := store.GetWorkItem(ctx, operation.ID) + if err != nil { + t.Fatal(err) + } + if _, err := store.StartOperation(ctx, operation.ID); err != nil { + t.Fatal(err) + } + completed, err := store.CompleteSync(ctx, work, operation.ID, gitrepo.Snapshot{CommitSHA: commitSHA, MirrorSizeBytes: 42}) + if err != nil { + t.Fatal(err) + } + if completed.Outcome != "no_change" || completed.SnapshotID == nil || *completed.SnapshotID != snapshotID { + t.Fatalf("unchanged operation = %+v, want existing snapshot %s", completed, snapshotID) + } + + var snapshotCount int + var currentSnapshotID uuid.UUID + if err := pool.QueryRow(ctx, `SELECT count(*) FROM commit_snapshots WHERE repository_id=$1`, repositoryID).Scan(&snapshotCount); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT current_snapshot_id FROM repositories WHERE id=$1`, repositoryID).Scan(¤tSnapshotID); err != nil { + t.Fatal(err) + } + if snapshotCount != 1 || currentSnapshotID != snapshotID { + t.Fatalf("snapshot count/current = %d/%s, want 1/%s", snapshotCount, currentSnapshotID, snapshotID) + } +} + +func TestRepositorySourceUpdateRollsBackOnFailureAndCommitsOnSuccess(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + manager, err := gitrepo.NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + store := NewStore(pool, manager) + + userID, workspaceID, repositoryID := uuid.New(), uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Source update',$2,$3)`, workspaceID, "source-update-"+workspaceID.String(), userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) +VALUES ($1,$2,'github','repo','https://github.com/example/repo.git','github.com/example/repo','main',$3,'ready',$4)`, repositoryID, workspaceID, manager.Path(repositoryID), userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO repository_source_keys (workspace_id,normalized_url,repository_id) VALUES ($1,'github.com/example/repo',$2)`, workspaceID, repositoryID); err != nil { + t.Fatal(err) + } + + operation := repository.Operation{ + ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: userID, + RepositoryVersion: 2, Kind: "update", + RequestedProviderType: "gitlab", RequestedRemoteURL: "https://gitlab.com/example/repo.git", + RequestedNormalizedURL: "gitlab.com/example/repo", RequestedRef: "release", + PreviousProviderType: "github", PreviousRemoteURL: "https://github.com/example/repo.git", + PreviousNormalizedURL: "github.com/example/repo", PreviousRef: "main", + Status: "queued", + } + staged, _, err := store.UpdateWithOperation(ctx, workspaceID, repositoryID, "repo", operation) + if err != nil { + t.Fatal(err) + } + if staged.Status != "syncing" || staged.ProviderType != "gitlab" || staged.RemoteURL != operation.RequestedRemoteURL || staged.ConfiguredRef != "release" { + t.Fatalf("staged repository source = %+v", staged) + } + competingID := uuid.New() + competing := repository.Repository{ + ID: competingID, WorkspaceID: workspaceID, ProviderType: "github", Name: "competing", + RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo", + ConfiguredRef: "main", Status: "provisioning", + } + competingOperation := repository.Operation{ + ID: uuid.New(), RepositoryID: competingID, ActorUserID: userID, + RepositoryVersion: 1, Kind: "provision", + RequestedProviderType: "github", RequestedRemoteURL: competing.RemoteURL, + RequestedNormalizedURL: competing.NormalizedURL, RequestedRef: "main", + PreviousProviderType: "github", PreviousRemoteURL: competing.RemoteURL, + PreviousNormalizedURL: competing.NormalizedURL, PreviousRef: "main", + Status: "queued", + } + if _, _, err := store.Create(ctx, competing, competingOperation); err == nil { + t.Fatal("source update released the previous repository URL before completion") + } + var competingCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM repositories WHERE id=$1`, competingID).Scan(&competingCount); err != nil { + t.Fatal(err) + } + if competingCount != 0 { + t.Fatal("failed source reservation did not roll back the competing repository") + } + work, err := store.GetWorkItem(ctx, operation.ID) + if err != nil { + t.Fatal(err) + } + if _, err := store.StartOperation(ctx, operation.ID); err != nil { + t.Fatal(err) + } + if err := store.FailOperation(ctx, work, repository.Failure{Code: "repository.sync_failed", Message: "Repository synchronization failed"}); err != nil { + t.Fatal(err) + } + rolledBack, err := store.Get(ctx, workspaceID, repositoryID) + if err != nil { + t.Fatal(err) + } + if rolledBack.Status != "failed" || rolledBack.ProviderType != "github" || rolledBack.RemoteURL != operation.PreviousRemoteURL || rolledBack.ConfiguredRef != "main" || rolledBack.ConfigVersion != 2 { + t.Fatalf("failed source update was not rolled back: %+v", rolledBack) + } + assertRepositorySourceKeys(t, pool, repositoryID, []string{"github.com/example/repo"}) + + operation.ID = uuid.New() + operation.RepositoryVersion = 3 + if _, _, err := store.UpdateWithOperation(ctx, workspaceID, repositoryID, "repo", operation); err != nil { + t.Fatal(err) + } + work, err = store.GetWorkItem(ctx, operation.ID) + if err != nil { + t.Fatal(err) + } + if _, err := store.StartOperation(ctx, operation.ID); err != nil { + t.Fatal(err) + } + if _, err := store.CompleteSync(ctx, work, operation.ID, gitrepo.Snapshot{CommitSHA: "2222222222222222222222222222222222222222", MirrorSizeBytes: 42}); err != nil { + t.Fatal(err) + } + committed, err := store.Get(ctx, workspaceID, repositoryID) + if err != nil { + t.Fatal(err) + } + if committed.Status != "ready" || committed.ProviderType != "gitlab" || committed.RemoteURL != operation.RequestedRemoteURL || committed.ConfiguredRef != "release" || committed.CurrentSnapshot == nil || committed.CurrentSnapshot.CommitSHA != "2222222222222222222222222222222222222222" { + t.Fatalf("successful source update was not committed: %+v", committed) + } + assertRepositorySourceKeys(t, pool, repositoryID, []string{"gitlab.com/example/repo"}) + + operation.ID = uuid.New() + operation.Kind = "purge" + operation.PreviousProviderType = operation.RequestedProviderType + operation.PreviousRemoteURL = operation.RequestedRemoteURL + operation.PreviousNormalizedURL = operation.RequestedNormalizedURL + operation.PreviousRef = operation.RequestedRef + if _, err := store.CreateOperation(ctx, workspaceID, operation); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE repositories SET status='deleting',deleted_at=now() WHERE id=$1`, repositoryID); err != nil { + t.Fatal(err) + } + work, err = store.GetWorkItem(ctx, operation.ID) + if err != nil { + t.Fatal(err) + } + if _, err := store.StartOperation(ctx, operation.ID); err != nil { + t.Fatal(err) + } + if err := store.FailOperation(ctx, work, repository.Failure{Code: "repository.purge_failed", Message: "Repository deletion failed"}); err != nil { + t.Fatal(err) + } + var status, errorCode string + if err := pool.QueryRow(ctx, `SELECT status,COALESCE(last_error_code,'') FROM repositories WHERE id=$1`, repositoryID).Scan(&status, &errorCode); err != nil { + t.Fatal(err) + } + if status != "deleted" || errorCode != "repository.purge_failed" { + t.Fatalf("failed retention purge projection = (%q, %q), want deleted repository", status, errorCode) + } +} + +func assertRepositorySourceKeys(t *testing.T, pool *pgxpool.Pool, repositoryID uuid.UUID, want []string) { + t.Helper() + + rows, err := pool.Query(context.Background(), `SELECT normalized_url FROM repository_source_keys WHERE repository_id=$1 ORDER BY normalized_url`, repositoryID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var got []string + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + t.Fatal(err) + } + got = append(got, value) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if len(got) != len(want) { + t.Fatalf("repository source keys = %v, want %v", got, want) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("repository source keys = %v, want %v", got, want) + } + } +} + +func assertOperationPagination(t *testing.T, store *Store, repositoryID uuid.UUID) { + t.Helper() + + first, err := store.ListOperations(context.Background(), repositoryID, pagination.Request{Limit: 2}) + if err != nil { + t.Fatal(err) + } + if len(first.Items) != 2 || first.NextCursor == "" { + t.Fatalf("first operation page = %+v, want two items and a cursor", first) + } + request, err := pagination.Parse(first.NextCursor, "2") + if err != nil { + t.Fatal(err) + } + second, err := store.ListOperations(context.Background(), repositoryID, request) + if err != nil { + t.Fatal(err) + } + if len(second.Items) != 2 || second.NextCursor != "" { + t.Fatalf("second operation page = %+v, want final two items", second) + } + seen := make(map[uuid.UUID]struct{}, 4) + for _, operation := range append(first.Items, second.Items...) { + seen[operation.ID] = struct{}{} + } + if len(seen) != 4 { + t.Fatalf("operation pages contained duplicates: %+v %+v", first.Items, second.Items) + } +} + +func assertRepositoryAudit(t *testing.T, pool *pgxpool.Pool, workspaceID, actorID, repositoryID uuid.UUID) { + t.Helper() + + rows, err := pool.Query(context.Background(), `SELECT workspace_id,actor_user_id,action FROM audit_events WHERE resource_type=$1 AND resource_id=$2`, audit.ResourceRepository, repositoryID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + seen := make(map[string]int) + for rows.Next() { + var eventWorkspaceID, eventActorID uuid.UUID + var action string + if err := rows.Scan(&eventWorkspaceID, &eventActorID, &action); err != nil { + t.Fatal(err) + } + if eventWorkspaceID != workspaceID || eventActorID != actorID { + t.Fatalf("repository audit identity = (%s, %s), want (%s, %s)", eventWorkspaceID, eventActorID, workspaceID, actorID) + } + seen[action]++ + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if seen[audit.ActionRepositoryRefreshRequested] != 4 || seen[audit.ActionRepositoryOperationCancelled] != 1 { + t.Fatalf("repository audit actions = %v, want four refresh requests and one cancellation", seen) + } +} + +func assertRepositoryProjection(t *testing.T, pool *pgxpool.Pool, repositoryID uuid.UUID, wantStatus string, wantSize int64, wantCode string) { + t.Helper() + var status, code string + var size int64 + if err := pool.QueryRow(context.Background(), `SELECT status,mirror_size_bytes,COALESCE(last_error_code,'') FROM repositories WHERE id=$1`, repositoryID).Scan(&status, &size, &code); err != nil { + t.Fatal(err) + } + if status != wantStatus || size != wantSize || code != wantCode { + t.Fatalf("repository projection = (%q, %d, %q), want (%q, %d, %q)", status, size, code, wantStatus, wantSize, wantCode) + } +} diff --git a/internal/repository/identity.go b/internal/repository/identity.go deleted file mode 100644 index 412134b..0000000 --- a/internal/repository/identity.go +++ /dev/null @@ -1,39 +0,0 @@ -package repository - -import ( - "context" - "errors" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" -) - -var ( - ErrNotFound = errors.New("repository: not found") - ErrConflict = errors.New("repository: conflict") -) - -type IdentityStore interface { - WithinIdentityTx(ctx context.Context, fn func(IdentityStore) error) error - UpsertUser(ctx context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) - GetUserByExternalIdentity(ctx context.Context, issuer, subject string) (model.User, error) - GetUserForUpdate(ctx context.Context, userID uuid.UUID) (model.User, error) - ActivateUser(ctx context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) - GetPersonalWorkspace(ctx context.Context, userID uuid.UUID) (model.Workspace, error) - CreateWorkspace(ctx context.Context, workspace model.Workspace) (model.Workspace, error) - AddWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error - ListWorkspaces(ctx context.Context, userID uuid.UUID) ([]model.Workspace, error) - GetWorkspaceMembership(ctx context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error) - UpdateWorkspace(ctx context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error) - ListWorkspaceMembers(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) - UpsertWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error - DeleteWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID) error - CreateWorkspaceInvitation(ctx context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) - ListWorkspaceInvitations(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) - GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (model.WorkspaceInvitation, error) - AcceptWorkspaceInvitation(ctx context.Context, invitationID, userID uuid.UUID) error - RevokeWorkspaceInvitation(ctx context.Context, invitationID uuid.UUID) error - AppendAccountAudit(ctx context.Context, entry model.AccountAuditLog) error - AppendIdentityAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error - AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error -} diff --git a/internal/repository/memory/identity.go b/internal/repository/memory/identity.go deleted file mode 100644 index 0916f81..0000000 --- a/internal/repository/memory/identity.go +++ /dev/null @@ -1,483 +0,0 @@ -package memory - -import ( - "context" - "sync" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" -) - -type Option func(*Store) - -func WithClock(clock func() time.Time) Option { - return func(store *Store) { - if clock != nil { - store.clock = clock - } - } -} - -type Store struct { - mu sync.RWMutex - clock func() time.Time - users map[string]model.User - workspaces map[uuid.UUID]model.Workspace - members map[uuid.UUID]map[uuid.UUID]string - invitations map[uuid.UUID]model.WorkspaceInvitation - invitationTokens map[string]uuid.UUID - accountAudits []model.AccountAuditLog -} - -func New(opts ...Option) *Store { - store := &Store{ - clock: time.Now, users: make(map[string]model.User), - workspaces: make(map[uuid.UUID]model.Workspace), members: make(map[uuid.UUID]map[uuid.UUID]string), - invitations: make(map[uuid.UUID]model.WorkspaceInvitation), invitationTokens: make(map[string]uuid.UUID), - } - for _, option := range opts { - if option != nil { - option(store) - } - } - return store -} - -func (s *Store) WithinIdentityTx(_ context.Context, fn func(repository.IdentityStore) error) error { - s.mu.Lock() - defer s.mu.Unlock() - users, workspaces, members := cloneUsers(s.users), cloneWorkspaces(s.workspaces), cloneMembers(s.members) - invitations, invitationTokens := cloneInvitations(s.invitations), cloneInvitationTokens(s.invitationTokens) - audits := append([]model.AccountAuditLog(nil), s.accountAudits...) - tx := &identityTx{store: s} - if err := fn(tx); err != nil { - s.users, s.workspaces, s.members = users, workspaces, members - s.invitations, s.invitationTokens, s.accountAudits = invitations, invitationTokens, audits - return err - } - return nil -} - -func (s *Store) UpsertUser(_ context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.upsertUser(id, identity), nil -} - -func (s *Store) GetUserByExternalIdentity(_ context.Context, issuer, subject string) (model.User, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.getUserByExternalIdentity(issuer, subject) -} - -func (s *Store) GetUserForUpdate(_ context.Context, userID uuid.UUID) (model.User, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.getUserByID(userID) -} - -func (s *Store) ActivateUser(_ context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.activateUser(userID, termsVersion, privacyVersion) -} - -func (s *Store) GetPersonalWorkspace(_ context.Context, userID uuid.UUID) (model.Workspace, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.personalWorkspace(userID) -} - -func (s *Store) CreateWorkspace(_ context.Context, workspace model.Workspace) (model.Workspace, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.createWorkspace(workspace), nil -} - -func (s *Store) AddWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID, role string) error { - s.mu.Lock() - defer s.mu.Unlock() - return s.addMember(workspaceID, userID, role) -} - -func (s *Store) ListWorkspaces(_ context.Context, userID uuid.UUID) ([]model.Workspace, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.listWorkspaces(userID), nil -} - -func (s *Store) GetWorkspaceMembership(_ context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.workspaceMembership(workspaceID, userID) -} -func (s *Store) UpdateWorkspace(_ context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.updateWorkspace(workspaceID, name) -} -func (s *Store) ListWorkspaceMembers(_ context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.listMembers(workspaceID), nil -} -func (s *Store) UpsertWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID, role string) error { - s.mu.Lock() - defer s.mu.Unlock() - return s.addMember(workspaceID, userID, role) -} -func (s *Store) DeleteWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID) error { - s.mu.Lock() - defer s.mu.Unlock() - return s.deleteMember(workspaceID, userID) -} -func (s *Store) CreateWorkspaceInvitation(_ context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.createInvitation(invitation, tokenHash) -} -func (s *Store) ListWorkspaceInvitations(_ context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.listInvitations(workspaceID), nil -} -func (s *Store) GetWorkspaceInvitationForUpdate(_ context.Context, tokenHash []byte) (model.WorkspaceInvitation, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.getInvitation(tokenHash) -} -func (s *Store) AcceptWorkspaceInvitation(_ context.Context, invitationID, userID uuid.UUID) error { - s.mu.Lock() - defer s.mu.Unlock() - return s.acceptInvitation(invitationID, userID) -} -func (s *Store) RevokeWorkspaceInvitation(_ context.Context, invitationID uuid.UUID) error { - s.mu.Lock() - defer s.mu.Unlock() - return s.revokeInvitation(invitationID) -} -func (s *Store) AppendAccountAudit(_ context.Context, entry model.AccountAuditLog) error { - s.mu.Lock() - defer s.mu.Unlock() - s.appendAccountAudit(entry) - return nil -} -func (*Store) AppendIdentityAudit(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, []byte) error { - return nil -} -func (*Store) AppendAuditResult(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, string, []byte) error { - return nil -} - -type identityTx struct{ store *Store } - -func (t *identityTx) WithinIdentityTx(_ context.Context, fn func(repository.IdentityStore) error) error { - return fn(t) -} -func (t *identityTx) UpsertUser(_ context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) { - return t.store.upsertUser(id, identity), nil -} -func (t *identityTx) GetUserByExternalIdentity(_ context.Context, issuer, subject string) (model.User, error) { - return t.store.getUserByExternalIdentity(issuer, subject) -} -func (t *identityTx) GetUserForUpdate(_ context.Context, userID uuid.UUID) (model.User, error) { - return t.store.getUserByID(userID) -} -func (t *identityTx) ActivateUser(_ context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) { - return t.store.activateUser(userID, termsVersion, privacyVersion) -} -func (t *identityTx) GetPersonalWorkspace(_ context.Context, userID uuid.UUID) (model.Workspace, error) { - return t.store.personalWorkspace(userID) -} -func (t *identityTx) CreateWorkspace(_ context.Context, workspace model.Workspace) (model.Workspace, error) { - return t.store.createWorkspace(workspace), nil -} -func (t *identityTx) AddWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID, role string) error { - return t.store.addMember(workspaceID, userID, role) -} -func (t *identityTx) ListWorkspaces(_ context.Context, userID uuid.UUID) ([]model.Workspace, error) { - return t.store.listWorkspaces(userID), nil -} -func (t *identityTx) GetWorkspaceMembership(_ context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error) { - return t.store.workspaceMembership(workspaceID, userID) -} -func (t *identityTx) UpdateWorkspace(_ context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error) { - return t.store.updateWorkspace(workspaceID, name) -} -func (t *identityTx) ListWorkspaceMembers(_ context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) { - return t.store.listMembers(workspaceID), nil -} -func (t *identityTx) UpsertWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID, role string) error { - return t.store.addMember(workspaceID, userID, role) -} -func (t *identityTx) DeleteWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID) error { - return t.store.deleteMember(workspaceID, userID) -} -func (t *identityTx) CreateWorkspaceInvitation(_ context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) { - return t.store.createInvitation(invitation, tokenHash) -} -func (t *identityTx) ListWorkspaceInvitations(_ context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) { - return t.store.listInvitations(workspaceID), nil -} -func (t *identityTx) GetWorkspaceInvitationForUpdate(_ context.Context, tokenHash []byte) (model.WorkspaceInvitation, error) { - return t.store.getInvitation(tokenHash) -} -func (t *identityTx) AcceptWorkspaceInvitation(_ context.Context, invitationID, userID uuid.UUID) error { - return t.store.acceptInvitation(invitationID, userID) -} -func (t *identityTx) RevokeWorkspaceInvitation(_ context.Context, invitationID uuid.UUID) error { - return t.store.revokeInvitation(invitationID) -} -func (t *identityTx) AppendAccountAudit(_ context.Context, entry model.AccountAuditLog) error { - t.store.appendAccountAudit(entry) - return nil -} -func (*identityTx) AppendIdentityAudit(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, []byte) error { - return nil -} -func (*identityTx) AppendAuditResult(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, string, []byte) error { - return nil -} - -func (s *Store) upsertUser(id uuid.UUID, identity model.ExternalIdentity) model.User { - key := identity.Issuer + "\x00" + identity.Subject - now := s.clock().UTC() - user, exists := s.users[key] - if !exists { - user = model.User{ID: id, Issuer: identity.Issuer, ExternalSubject: identity.Subject, Status: "pending", CreatedAt: now} - } - user.Username, user.Email, user.DisplayName, user.UpdatedAt, user.LastSeenAt = identity.Username, identity.Email, identity.DisplayName, now, timePointer(now) - s.users[key] = user - return user -} - -func (s *Store) getUserByExternalIdentity(issuer, subject string) (model.User, error) { - user, ok := s.users[issuer+"\x00"+subject] - if !ok { - return model.User{}, repository.ErrNotFound - } - return user, nil -} - -func (s *Store) getUserByID(userID uuid.UUID) (model.User, error) { - for _, user := range s.users { - if user.ID == userID { - return user, nil - } - } - return model.User{}, repository.ErrNotFound -} - -func (s *Store) activateUser(userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) { - for key, user := range s.users { - if user.ID != userID { - continue - } - now := s.clock().UTC() - if user.Status == "pending" { - user.Status = "active" - user.TermsVersion, user.PrivacyVersion = termsVersion, privacyVersion - user.ActivatedAt, user.AgreementsAcceptedAt = timePointer(now), timePointer(now) - user.UpdatedAt = now - s.users[key] = user - } - return user, nil - } - return model.User{}, repository.ErrNotFound -} - -func (s *Store) personalWorkspace(userID uuid.UUID) (model.Workspace, error) { - for id, roles := range s.members { - if role, ok := roles[userID]; ok && s.workspaces[id].Kind == "personal" { - workspace := s.workspaces[id] - workspace.Role = role - return workspace, nil - } - } - return model.Workspace{}, repository.ErrNotFound -} - -func (s *Store) createWorkspace(workspace model.Workspace) model.Workspace { - now := s.clock().UTC() - workspace.CreatedAt, workspace.UpdatedAt = now, now - s.workspaces[workspace.ID] = workspace - return workspace -} - -func (s *Store) addMember(workspaceID, userID uuid.UUID, role string) error { - if _, ok := s.workspaces[workspaceID]; !ok { - return repository.ErrNotFound - } - if s.members[workspaceID] == nil { - s.members[workspaceID] = make(map[uuid.UUID]string) - } - if _, exists := s.members[workspaceID][userID]; exists { - return repository.ErrConflict - } - s.members[workspaceID][userID] = role - return nil -} - -func (s *Store) listWorkspaces(userID uuid.UUID) []model.Workspace { - result := make([]model.Workspace, 0) - for id, roles := range s.members { - if role, ok := roles[userID]; ok { - workspace := s.workspaces[id] - workspace.Role = role - result = append(result, workspace) - } - } - return result -} - -func (s *Store) workspaceMembership(workspaceID, userID uuid.UUID) (model.Workspace, error) { - role, ok := s.members[workspaceID][userID] - if !ok { - return model.Workspace{}, repository.ErrNotFound - } - workspace := s.workspaces[workspaceID] - workspace.Role = role - return workspace, nil -} - -func (s *Store) updateWorkspace(workspaceID uuid.UUID, name string) (model.Workspace, error) { - workspace, ok := s.workspaces[workspaceID] - if !ok { - return model.Workspace{}, repository.ErrNotFound - } - workspace.Name, workspace.UpdatedAt = name, s.clock().UTC() - s.workspaces[workspaceID] = workspace - return workspace, nil -} - -func (s *Store) listMembers(workspaceID uuid.UUID) []model.WorkspaceMember { - result := make([]model.WorkspaceMember, 0, len(s.members[workspaceID])) - for userID, role := range s.members[workspaceID] { - var user model.User - for _, candidate := range s.users { - if candidate.ID == userID { - user = candidate - break - } - } - result = append(result, model.WorkspaceMember{WorkspaceID: workspaceID, UserID: userID, Role: role, Username: user.Username, Email: user.Email, DisplayName: user.DisplayName}) - } - return result -} - -func (s *Store) deleteMember(workspaceID, userID uuid.UUID) error { - if _, ok := s.members[workspaceID][userID]; !ok { - return repository.ErrNotFound - } - delete(s.members[workspaceID], userID) - return nil -} - -func (s *Store) createInvitation(invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) { - key := string(tokenHash) - if _, exists := s.invitationTokens[key]; exists { - return model.WorkspaceInvitation{}, repository.ErrConflict - } - now := s.clock().UTC() - invitation.CreatedAt, invitation.UpdatedAt = now, now - s.invitations[invitation.ID] = invitation - s.invitationTokens[key] = invitation.ID - return invitation, nil -} - -func (s *Store) listInvitations(workspaceID uuid.UUID) []model.WorkspaceInvitation { - result := make([]model.WorkspaceInvitation, 0) - for _, invitation := range s.invitations { - if invitation.WorkspaceID == workspaceID { - result = append(result, invitation) - } - } - return result -} - -func (s *Store) getInvitation(tokenHash []byte) (model.WorkspaceInvitation, error) { - id, ok := s.invitationTokens[string(tokenHash)] - if !ok { - return model.WorkspaceInvitation{}, repository.ErrNotFound - } - return s.invitations[id], nil -} - -func (s *Store) acceptInvitation(invitationID, userID uuid.UUID) error { - invitation, ok := s.invitations[invitationID] - if !ok || invitation.AcceptedAt != nil || invitation.RevokedAt != nil { - return repository.ErrConflict - } - now := s.clock().UTC() - invitation.AcceptedAt, invitation.AcceptedBy, invitation.UpdatedAt = timePointer(now), uuidPointer(userID), now - s.invitations[invitationID] = invitation - return nil -} - -func (s *Store) revokeInvitation(invitationID uuid.UUID) error { - invitation, ok := s.invitations[invitationID] - if !ok { - return repository.ErrNotFound - } - if invitation.AcceptedAt != nil || invitation.RevokedAt != nil { - return repository.ErrConflict - } - now := s.clock().UTC() - invitation.RevokedAt, invitation.UpdatedAt = timePointer(now), now - s.invitations[invitationID] = invitation - return nil -} - -func (s *Store) appendAccountAudit(entry model.AccountAuditLog) { - if entry.ID == uuid.Nil { - entry.ID = uuid.New() - } - if entry.OccurredAt.IsZero() { - entry.OccurredAt = s.clock().UTC() - } - s.accountAudits = append(s.accountAudits, entry) -} - -func timePointer(value time.Time) *time.Time { return &value } -func uuidPointer(value uuid.UUID) *uuid.UUID { return &value } - -func cloneUsers(source map[string]model.User) map[string]model.User { - result := make(map[string]model.User, len(source)) - for key, value := range source { - result[key] = value - } - return result -} -func cloneWorkspaces(source map[uuid.UUID]model.Workspace) map[uuid.UUID]model.Workspace { - result := make(map[uuid.UUID]model.Workspace, len(source)) - for key, value := range source { - result[key] = value - } - return result -} -func cloneMembers(source map[uuid.UUID]map[uuid.UUID]string) map[uuid.UUID]map[uuid.UUID]string { - result := make(map[uuid.UUID]map[uuid.UUID]string, len(source)) - for workspaceID, members := range source { - result[workspaceID] = make(map[uuid.UUID]string, len(members)) - for userID, role := range members { - result[workspaceID][userID] = role - } - } - return result -} -func cloneInvitations(source map[uuid.UUID]model.WorkspaceInvitation) map[uuid.UUID]model.WorkspaceInvitation { - result := make(map[uuid.UUID]model.WorkspaceInvitation, len(source)) - for key, value := range source { - result[key] = value - } - return result -} -func cloneInvitationTokens(source map[string]uuid.UUID) map[string]uuid.UUID { - result := make(map[string]uuid.UUID, len(source)) - for key, value := range source { - result[key] = value - } - return result -} diff --git a/internal/repository/mutation.go b/internal/repository/mutation.go deleted file mode 100644 index 7487e81..0000000 --- a/internal/repository/mutation.go +++ /dev/null @@ -1,32 +0,0 @@ -package repository - -import ( - "context" - "time" - - "github.com/google/uuid" -) - -type MutationRequest struct { - WorkspaceID uuid.UUID - Operation string - ResourceKey string - IdempotencyKey string - RequestHash string - ResourceID uuid.UUID - ActiveUntil time.Time - NextAllowedAt time.Time -} - -type MutationClaim struct { - Replay bool - ResourceID uuid.UUID -} - -// MutationStore provides a database-backed gate for expensive or high-risk -// synchronous changes. Implementations must serialize BeginMutation for a -// workspace/operation/resource key across process replicas. -type MutationStore interface { - BeginMutation(ctx context.Context, request MutationRequest) (MutationClaim, error) - FinishMutation(ctx context.Context, request MutationRequest, succeeded bool) error -} diff --git a/internal/repository/postgres/channel.go b/internal/repository/postgres/channel.go deleted file mode 100644 index 6c57ca3..0000000 --- a/internal/repository/postgres/channel.go +++ /dev/null @@ -1,277 +0,0 @@ -package postgres - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc" - channelcore "github.com/mooncode-ai/mooncode/pkg/channel" -) - -type channelTx struct{ queries *db.Queries } - -func (s *Store) WithinChannelTx(ctx context.Context, fn func(repository.ChannelMutationStore) error) error { - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return err - } - defer func() { _ = tx.Rollback(ctx) }() - if err := fn(&channelTx{queries: s.queries.WithTx(tx)}); err != nil { - return err - } - return tx.Commit(ctx) -} - -func (s *Store) CreateChannel(ctx context.Context, value model.ChannelInstance) (model.ChannelInstance, error) { - return createChannel(ctx, s.queries, value) -} -func (t *channelTx) CreateChannel(ctx context.Context, value model.ChannelInstance) (model.ChannelInstance, error) { - return createChannel(ctx, t.queries, value) -} -func createChannel(ctx context.Context, q *db.Queries, value model.ChannelInstance) (model.ChannelInstance, error) { - configJSON, err := json.Marshal(value.Config) - if err != nil { - return model.ChannelInstance{}, err - } - row, err := q.CreateChannelInstance(ctx, db.CreateChannelInstanceParams{ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), Type: value.Type, Name: value.Name, Config: configJSON, SecretRef: nullableUUID(value.SecretRef)}) - if err != nil { - return model.ChannelInstance{}, mapMetadataError(err) - } - return channelFromDB(row) -} - -func (s *Store) ListChannels(ctx context.Context, workspaceID uuid.UUID) ([]model.ChannelInstance, error) { - rows, err := s.queries.ListChannelInstances(ctx, uuidToPG(workspaceID)) - if err != nil { - return nil, err - } - return channelsFromDB(rows) -} -func (s *Store) GetChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error) { - row, err := s.queries.GetChannelInstance(ctx, db.GetChannelInstanceParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - if err != nil { - return model.ChannelInstance{}, mapMetadataError(err) - } - return channelFromDB(row) -} -func (s *Store) ListEnabledChannels(ctx context.Context) ([]model.ChannelInstance, error) { - rows, err := s.queries.ListEnabledChannelInstances(ctx) - if err != nil { - return nil, err - } - return channelsFromDB(rows) -} - -func (s *Store) UpdateChannel(ctx context.Context, value model.ChannelInstance, expected int64) (model.ChannelInstance, error) { - return updateChannel(ctx, s.queries, value, expected) -} -func (t *channelTx) UpdateChannel(ctx context.Context, value model.ChannelInstance, expected int64) (model.ChannelInstance, error) { - return updateChannel(ctx, t.queries, value, expected) -} -func updateChannel(ctx context.Context, q *db.Queries, value model.ChannelInstance, expected int64) (model.ChannelInstance, error) { - configJSON, err := json.Marshal(value.Config) - if err != nil { - return model.ChannelInstance{}, err - } - row, err := q.UpdateChannelInstance(ctx, db.UpdateChannelInstanceParams{Name: value.Name, Config: configJSON, SecretRef: nullableUUID(value.SecretRef), ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), ExpectedVersion: expected}) - if err != nil { - return model.ChannelInstance{}, mapMetadataError(err) - } - return channelFromDB(row) -} - -func (s *Store) SetChannelEnabled(ctx context.Context, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error) { - return setChannelEnabled(ctx, s.queries, workspaceID, id, enabled) -} -func (t *channelTx) SetChannelEnabled(ctx context.Context, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error) { - return setChannelEnabled(ctx, t.queries, workspaceID, id, enabled) -} -func setChannelEnabled(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error) { - row, err := q.SetChannelEnabled(ctx, db.SetChannelEnabledParams{Enabled: enabled, ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - if err != nil { - return model.ChannelInstance{}, mapMetadataError(err) - } - return channelFromDB(row) -} - -func (s *Store) SoftDeleteChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error) { - return softDeleteChannel(ctx, s.queries, workspaceID, id) -} -func (t *channelTx) SoftDeleteChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error) { - return softDeleteChannel(ctx, t.queries, workspaceID, id) -} -func softDeleteChannel(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.ChannelInstance, error) { - row, err := q.SoftDeleteChannel(ctx, db.SoftDeleteChannelParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - if err != nil { - return model.ChannelInstance{}, mapMetadataError(err) - } - return channelFromDB(row) -} - -func (s *Store) AcquireChannelLease(ctx context.Context, instanceID uuid.UUID, owner string, until time.Time) (model.ChannelLease, error) { - row, err := s.queries.AcquireChannelLease(ctx, db.AcquireChannelLeaseParams{ChannelInstanceID: uuidToPG(instanceID), Owner: owner, LeaseUntil: timeToPG(until)}) - if err != nil { - return model.ChannelLease{}, mapMetadataError(err) - } - return model.ChannelLease{ChannelInstanceID: uuidFromPG(row.ChannelInstanceID), Owner: row.Owner, LeaseUntil: row.LeaseUntil.Time, FencingToken: row.FencingToken}, nil -} -func (s *Store) RenewChannelLease(ctx context.Context, lease model.ChannelLease, until time.Time) error { - rows, err := s.queries.RenewChannelLease(ctx, db.RenewChannelLeaseParams{LeaseUntil: timeToPG(until), ChannelInstanceID: uuidToPG(lease.ChannelInstanceID), Owner: lease.Owner, FencingToken: lease.FencingToken}) - return requireAffected(rows, err) -} -func (s *Store) ReleaseChannelLease(ctx context.Context, lease model.ChannelLease) error { - return s.queries.ReleaseChannelLease(ctx, db.ReleaseChannelLeaseParams{ChannelInstanceID: uuidToPG(lease.ChannelInstanceID), Owner: lease.Owner, FencingToken: lease.FencingToken}) -} - -func (s *Store) SetChannelStatus(ctx context.Context, status model.ChannelRuntimeStatus) error { - message := status.LastErrorMessage - if len(message) > 2048 { - message = message[:2048] - } - rows, err := s.queries.SetChannelRuntimeStatus(ctx, db.SetChannelRuntimeStatusParams{ChannelInstanceID: uuidToPG(status.ChannelInstanceID), State: status.State, BackendInstanceID: status.BackendInstanceID, FencingToken: status.FencingToken, LastConnectedAt: nullableTime(status.LastConnectedAt), LastErrorCode: status.LastErrorCode, LastErrorMessage: message}) - if err != nil { - return err - } - if rows == 0 { - return repository.ErrLeaseLost - } - return nil -} -func (s *Store) GetChannelStatus(ctx context.Context, instanceID uuid.UUID) (model.ChannelRuntimeStatus, error) { - row, err := s.queries.GetChannelRuntimeStatus(ctx, uuidToPG(instanceID)) - if err != nil { - return model.ChannelRuntimeStatus{}, mapMetadataError(err) - } - return model.ChannelRuntimeStatus{ChannelInstanceID: uuidFromPG(row.ChannelInstanceID), State: row.State, BackendInstanceID: row.BackendInstanceID, FencingToken: row.FencingToken, LastConnectedAt: timePointer(row.LastConnectedAt), LastErrorCode: row.LastErrorCode, LastErrorMessage: row.LastErrorMessage, UpdatedAt: row.UpdatedAt.Time}, nil -} - -func (s *Store) AcceptInbound(ctx context.Context, instance model.ChannelInstance, message channelcore.InboundMessage) (bool, error) { - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return false, err - } - defer func() { _ = tx.Rollback(ctx) }() - q := s.queries.WithTx(tx) - payload, err := json.Marshal(struct { - Content channelcore.MessageContent `json:"content"` - Mentioned bool `json:"mentioned"` - ReplyToMessageID string `json:"replyToMessageId,omitempty"` - Attributes map[string]string `json:"attributes,omitempty"` - }{message.Content, message.Mentioned, message.ReplyToMessageID, message.Attributes}) - if err != nil { - return false, err - } - hash := sha256.Sum256(payload) - if _, err := q.InsertInboxEvent(ctx, db.InsertInboxEventParams{ChannelInstanceID: uuidToPG(instance.ID), ExternalEventID: message.ExternalEventID, PayloadHash: hex.EncodeToString(hash[:])}); errors.Is(err, pgx.ErrNoRows) { - return false, tx.Commit(ctx) - } else if err != nil { - return false, err - } - conversationID, err := uuid.NewV7() - if err != nil { - return false, err - } - conversation, err := q.UpsertIMConversation(ctx, db.UpsertIMConversationParams{ID: uuidToPG(conversationID), WorkspaceID: uuidToPG(instance.WorkspaceID), ChannelInstanceID: uuidToPG(instance.ID), ExternalID: message.Conversation.ID, Type: message.Conversation.Type, Title: message.Attributes["conversation_title"]}) - if err != nil { - return false, err - } - senderID, err := uuid.NewV7() - if err != nil { - return false, err - } - sender, err := q.UpsertIMSender(ctx, db.UpsertIMSenderParams{ID: uuidToPG(senderID), WorkspaceID: uuidToPG(instance.WorkspaceID), ChannelType: instance.Type, CanonicalID: message.Sender.CanonicalID, DisplayName: message.Sender.DisplayName}) - if err != nil { - return false, err - } - messageID, err := uuid.NewV7() - if err != nil { - return false, err - } - inserted, err := q.InsertIMMessage(ctx, db.InsertIMMessageParams{ID: uuidToPG(messageID), WorkspaceID: uuidToPG(instance.WorkspaceID), ChannelInstanceID: uuidToPG(instance.ID), ConversationID: conversation.ID, SenderID: sender.ID, ExternalMessageID: message.ExternalMessageID, Content: payload, OccurredAt: timeToPG(message.OccurredAt)}) - if errors.Is(err, pgx.ErrNoRows) { - return false, tx.Commit(ctx) - } else if err != nil { - return false, err - } - eventID, err := uuid.NewV7() - if err != nil { - return false, err - } - eventPayload, _ := json.Marshal(map[string]string{"messageId": uuidFromPG(inserted.ID).String(), "channelInstanceId": instance.ID.String()}) - if err := q.AppendOutboxEvent(ctx, db.AppendOutboxEventParams{ID: uuidToPG(eventID), WorkspaceID: uuidToPG(instance.WorkspaceID), Aggregate: "im_message", AggregateID: inserted.ID, EventType: "im.message_received", Payload: eventPayload, TraceContext: encodeTraceContext(ctx, s.propagator)}); err != nil { - return false, err - } - return true, tx.Commit(ctx) -} - -func (s *Store) ListMessages(ctx context.Context, workspaceID uuid.UUID, channelID, conversationID *uuid.UUID, cursor repository.MessageCursor, pageSize int32) ([]model.IMMessage, error) { - params := db.ListIMMessagesParams{WorkspaceID: uuidToPG(workspaceID), ChannelInstanceID: nullableUUID(channelID), ConversationID: nullableUUID(conversationID), PageSize: pageSize} - if cursor.BeforeTime != nil { - params.BeforeTime = nullableTime(cursor.BeforeTime) - params.BeforeID = nullableUUID(cursor.BeforeID) - } - rows, err := s.queries.ListIMMessages(ctx, params) - if err != nil { - return nil, err - } - result := make([]model.IMMessage, 0, len(rows)) - for _, row := range rows { - result = append(result, model.IMMessage{ID: uuidFromPG(row.ID), ChannelInstanceID: uuidFromPG(row.ChannelInstanceID), ChannelName: row.ChannelName, ChannelType: row.ChannelType, ConversationID: uuidFromPG(row.ConversationID), ConversationExternalID: row.ConversationExternalID, ConversationType: row.ConversationType, SenderCanonicalID: row.SenderCanonicalID, SenderDisplayName: row.SenderDisplayName, ExternalMessageID: row.ExternalMessageID, Content: row.Content, OccurredAt: row.OccurredAt.Time, ReceivedAt: row.ReceivedAt.Time}) - } - return result, nil -} -func (s *Store) ListConversations(ctx context.Context, workspaceID uuid.UUID, pageSize int32) ([]model.IMConversation, error) { - rows, err := s.queries.ListIMConversations(ctx, db.ListIMConversationsParams{WorkspaceID: uuidToPG(workspaceID), PageSize: pageSize}) - if err != nil { - return nil, err - } - result := make([]model.IMConversation, 0, len(rows)) - for _, row := range rows { - result = append(result, model.IMConversation{ID: uuidFromPG(row.ID), ChannelInstanceID: uuidFromPG(row.ChannelInstanceID), ChannelName: row.ChannelName, ChannelType: row.ChannelType, ExternalID: row.ExternalID, Type: row.Type, Title: row.Title, UpdatedAt: row.UpdatedAt.Time}) - } - return result, nil -} - -func (t *channelTx) AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error { - return appendAudit(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, metadata) -} -func (t *channelTx) AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error { - return appendAuditResult(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, result, metadata) -} -func channelFromDB(row db.ChannelInstance) (model.ChannelInstance, error) { - var config model.ChannelConfig - if err := json.Unmarshal(row.Config, &config); err != nil { - return model.ChannelInstance{}, err - } - secret := uuidPointer(row.SecretRef) - return model.ChannelInstance{ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Type: row.Type, Name: row.Name, Enabled: row.Enabled, Config: config, SecretRef: secret, SecretConfigured: secret != nil, ConfigVersion: row.ConfigVersion, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time}, nil -} -func channelsFromDB(rows []db.ChannelInstance) ([]model.ChannelInstance, error) { - result := make([]model.ChannelInstance, 0, len(rows)) - for _, row := range rows { - value, err := channelFromDB(row) - if err != nil { - return nil, err - } - result = append(result, value) - } - return result, nil -} -func nullableTime(value *time.Time) pgtype.Timestamptz { - if value == nil { - return pgtype.Timestamptz{} - } - return timeToPG(*value) -} - -var _ repository.ChannelStore = (*Store)(nil) -var _ repository.ChannelMutationStore = (*channelTx)(nil) diff --git a/internal/repository/postgres/identity.go b/internal/repository/postgres/identity.go deleted file mode 100644 index 1a8af8c..0000000 --- a/internal/repository/postgres/identity.go +++ /dev/null @@ -1,458 +0,0 @@ -package postgres - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc" -) - -type identityTx struct { - queries *db.Queries -} - -func (s *Store) WithinIdentityTx(ctx context.Context, fn func(repository.IdentityStore) error) error { - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return err - } - defer func() { _ = tx.Rollback(ctx) }() - if err := fn(&identityTx{queries: s.queries.WithTx(tx)}); err != nil { - return err - } - return tx.Commit(ctx) -} - -func (s *Store) UpsertUser(ctx context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) { - return upsertUser(ctx, s.queries, id, identity) -} - -func (s *Store) GetUserByExternalIdentity(ctx context.Context, issuer, subject string) (model.User, error) { - return getUserByExternalIdentity(ctx, s.queries, issuer, subject) -} - -func (s *Store) GetUserForUpdate(ctx context.Context, userID uuid.UUID) (model.User, error) { - return getUserForUpdate(ctx, s.queries, userID) -} - -func (s *Store) ActivateUser(ctx context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) { - return activateUser(ctx, s.queries, userID, termsVersion, privacyVersion) -} - -func (s *Store) GetPersonalWorkspace(ctx context.Context, userID uuid.UUID) (model.Workspace, error) { - return getPersonalWorkspace(ctx, s.queries, userID) -} - -func (s *Store) CreateWorkspace(ctx context.Context, workspace model.Workspace) (model.Workspace, error) { - return createWorkspace(ctx, s.queries, workspace) -} - -func (s *Store) AddWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error { - return addWorkspaceMember(ctx, s.queries, workspaceID, userID, role) -} - -func (s *Store) ListWorkspaces(ctx context.Context, userID uuid.UUID) ([]model.Workspace, error) { - return listWorkspaces(ctx, s.queries, userID) -} - -func (s *Store) GetWorkspaceMembership(ctx context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error) { - return getWorkspaceMembership(ctx, s.queries, workspaceID, userID) -} -func (s *Store) UpdateWorkspace(ctx context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error) { - return updateWorkspace(ctx, s.queries, workspaceID, name) -} -func (s *Store) ListWorkspaceMembers(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) { - return listWorkspaceMembers(ctx, s.queries, workspaceID) -} -func (s *Store) UpsertWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error { - return upsertWorkspaceMember(ctx, s.queries, workspaceID, userID, role) -} -func (s *Store) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID) error { - return deleteWorkspaceMember(ctx, s.queries, workspaceID, userID) -} -func (s *Store) CreateWorkspaceInvitation(ctx context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) { - return createWorkspaceInvitation(ctx, s.queries, invitation, tokenHash) -} -func (s *Store) ListWorkspaceInvitations(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) { - return listWorkspaceInvitations(ctx, s.queries, workspaceID) -} -func (s *Store) GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (model.WorkspaceInvitation, error) { - return getWorkspaceInvitationForUpdate(ctx, s.queries, tokenHash) -} -func (s *Store) AcceptWorkspaceInvitation(ctx context.Context, invitationID, userID uuid.UUID) error { - return acceptWorkspaceInvitation(ctx, s.queries, invitationID, userID) -} -func (s *Store) RevokeWorkspaceInvitation(ctx context.Context, invitationID uuid.UUID) error { - return revokeWorkspaceInvitation(ctx, s.queries, invitationID) -} -func (s *Store) AppendAccountAudit(ctx context.Context, entry model.AccountAuditLog) error { - return appendAccountAudit(ctx, s.queries, entry) -} -func (s *Store) AppendIdentityAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error { - return appendAudit(ctx, s.queries, workspaceID, actorID, action, resourceType, resourceID, metadata) -} - -func (t *identityTx) WithinIdentityTx(_ context.Context, fn func(repository.IdentityStore) error) error { - return fn(t) -} - -func (t *identityTx) UpsertUser(ctx context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) { - return upsertUser(ctx, t.queries, id, identity) -} - -func (t *identityTx) GetUserByExternalIdentity(ctx context.Context, issuer, subject string) (model.User, error) { - return getUserByExternalIdentity(ctx, t.queries, issuer, subject) -} - -func (t *identityTx) GetUserForUpdate(ctx context.Context, userID uuid.UUID) (model.User, error) { - return getUserForUpdate(ctx, t.queries, userID) -} - -func (t *identityTx) ActivateUser(ctx context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) { - return activateUser(ctx, t.queries, userID, termsVersion, privacyVersion) -} - -func (t *identityTx) GetPersonalWorkspace(ctx context.Context, userID uuid.UUID) (model.Workspace, error) { - return getPersonalWorkspace(ctx, t.queries, userID) -} - -func (t *identityTx) CreateWorkspace(ctx context.Context, workspace model.Workspace) (model.Workspace, error) { - return createWorkspace(ctx, t.queries, workspace) -} - -func (t *identityTx) AddWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error { - return addWorkspaceMember(ctx, t.queries, workspaceID, userID, role) -} - -func (t *identityTx) ListWorkspaces(ctx context.Context, userID uuid.UUID) ([]model.Workspace, error) { - return listWorkspaces(ctx, t.queries, userID) -} - -func (t *identityTx) GetWorkspaceMembership(ctx context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error) { - return getWorkspaceMembership(ctx, t.queries, workspaceID, userID) -} -func (t *identityTx) UpdateWorkspace(ctx context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error) { - return updateWorkspace(ctx, t.queries, workspaceID, name) -} -func (t *identityTx) ListWorkspaceMembers(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) { - return listWorkspaceMembers(ctx, t.queries, workspaceID) -} -func (t *identityTx) UpsertWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error { - return upsertWorkspaceMember(ctx, t.queries, workspaceID, userID, role) -} -func (t *identityTx) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID) error { - return deleteWorkspaceMember(ctx, t.queries, workspaceID, userID) -} -func (t *identityTx) CreateWorkspaceInvitation(ctx context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) { - return createWorkspaceInvitation(ctx, t.queries, invitation, tokenHash) -} -func (t *identityTx) ListWorkspaceInvitations(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) { - return listWorkspaceInvitations(ctx, t.queries, workspaceID) -} -func (t *identityTx) GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (model.WorkspaceInvitation, error) { - return getWorkspaceInvitationForUpdate(ctx, t.queries, tokenHash) -} -func (t *identityTx) AcceptWorkspaceInvitation(ctx context.Context, invitationID, userID uuid.UUID) error { - return acceptWorkspaceInvitation(ctx, t.queries, invitationID, userID) -} -func (t *identityTx) RevokeWorkspaceInvitation(ctx context.Context, invitationID uuid.UUID) error { - return revokeWorkspaceInvitation(ctx, t.queries, invitationID) -} -func (t *identityTx) AppendAccountAudit(ctx context.Context, entry model.AccountAuditLog) error { - return appendAccountAudit(ctx, t.queries, entry) -} -func (t *identityTx) AppendIdentityAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error { - return appendAudit(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, metadata) -} -func (t *identityTx) AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error { - return appendAuditResult(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, result, metadata) -} - -func upsertUser(ctx context.Context, queries *db.Queries, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) { - row, err := queries.UpsertUser(ctx, db.UpsertUserParams{ - ID: uuidToPG(id), Issuer: identity.Issuer, ExternalSubject: identity.Subject, - Username: identity.Username, Email: identity.Email, DisplayName: identity.DisplayName, - }) - if err != nil { - return model.User{}, err - } - return userFromDB(row), nil -} - -func getUserByExternalIdentity(ctx context.Context, queries *db.Queries, issuer, subject string) (model.User, error) { - row, err := queries.GetUserByExternalIdentity(ctx, db.GetUserByExternalIdentityParams{Issuer: issuer, ExternalSubject: subject}) - if errors.Is(err, pgx.ErrNoRows) { - return model.User{}, repository.ErrNotFound - } - if err != nil { - return model.User{}, err - } - return userFromDB(row), nil -} - -func getUserForUpdate(ctx context.Context, queries *db.Queries, userID uuid.UUID) (model.User, error) { - row, err := queries.GetUserForUpdate(ctx, uuidToPG(userID)) - if errors.Is(err, pgx.ErrNoRows) { - return model.User{}, repository.ErrNotFound - } - if err != nil { - return model.User{}, err - } - return userFromDB(row), nil -} - -func activateUser(ctx context.Context, queries *db.Queries, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) { - row, err := queries.ActivateUser(ctx, db.ActivateUserParams{ID: uuidToPG(userID), TermsVersion: termsVersion, PrivacyVersion: privacyVersion}) - if errors.Is(err, pgx.ErrNoRows) { - return model.User{}, repository.ErrConflict - } - if err != nil { - return model.User{}, err - } - return userFromDB(row), nil -} - -func getPersonalWorkspace(ctx context.Context, queries *db.Queries, userID uuid.UUID) (model.Workspace, error) { - row, err := queries.GetPersonalWorkspaceForUser(ctx, uuidToPG(userID)) - if errors.Is(err, pgx.ErrNoRows) { - return model.Workspace{}, repository.ErrNotFound - } - if err != nil { - return model.Workspace{}, err - } - return workspaceFromDB(row, "owner"), nil -} - -func createWorkspace(ctx context.Context, queries *db.Queries, workspace model.Workspace) (model.Workspace, error) { - row, err := queries.CreateWorkspace(ctx, db.CreateWorkspaceParams{ - ID: uuidToPG(workspace.ID), Name: workspace.Name, Slug: workspace.Slug, - Kind: workspace.Kind, CreatedBy: uuidToPG(workspace.CreatedBy), - }) - if err != nil { - return model.Workspace{}, err - } - return workspaceFromDB(row, workspace.Role), nil -} - -func addWorkspaceMember(ctx context.Context, queries *db.Queries, workspaceID, userID uuid.UUID, role string) error { - _, err := queries.AddWorkspaceMember(ctx, db.AddWorkspaceMemberParams{ - WorkspaceID: uuidToPG(workspaceID), UserID: uuidToPG(userID), Role: role, - }) - if errors.Is(err, pgx.ErrNoRows) { - return repository.ErrConflict - } - return err -} - -func listWorkspaces(ctx context.Context, queries *db.Queries, userID uuid.UUID) ([]model.Workspace, error) { - rows, err := queries.ListWorkspacesForUser(ctx, uuidToPG(userID)) - if err != nil { - return nil, err - } - result := make([]model.Workspace, 0, len(rows)) - for _, row := range rows { - result = append(result, model.Workspace{ - ID: uuidFromPG(row.ID), Name: row.Name, Slug: row.Slug, Kind: row.Kind, - CreatedBy: uuidFromPG(row.CreatedBy), Role: row.Role, - CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, - }) - } - return result, nil -} - -func getWorkspaceMembership(ctx context.Context, queries *db.Queries, workspaceID, userID uuid.UUID) (model.Workspace, error) { - row, err := queries.GetWorkspaceMembership(ctx, db.GetWorkspaceMembershipParams{ - WorkspaceID: uuidToPG(workspaceID), UserID: uuidToPG(userID), - }) - if errors.Is(err, pgx.ErrNoRows) { - return model.Workspace{}, repository.ErrNotFound - } - if err != nil { - return model.Workspace{}, err - } - return model.Workspace{ - ID: uuidFromPG(row.ID), Name: row.Name, Slug: row.Slug, Kind: row.Kind, - CreatedBy: uuidFromPG(row.CreatedBy), Role: row.Role, - CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, - }, nil -} - -func updateWorkspace(ctx context.Context, queries *db.Queries, workspaceID uuid.UUID, name string) (model.Workspace, error) { - row, err := queries.UpdateWorkspace(ctx, db.UpdateWorkspaceParams{Name: name, ID: uuidToPG(workspaceID)}) - if err != nil { - return model.Workspace{}, mapMetadataError(err) - } - return workspaceFromDB(row, ""), nil -} - -func listWorkspaceMembers(ctx context.Context, queries *db.Queries, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) { - rows, err := queries.ListWorkspaceMembers(ctx, uuidToPG(workspaceID)) - if err != nil { - return nil, err - } - result := make([]model.WorkspaceMember, 0, len(rows)) - for _, row := range rows { - result = append(result, model.WorkspaceMember{ - WorkspaceID: uuidFromPG(row.WorkspaceID), UserID: uuidFromPG(row.UserID), Role: row.Role, - Username: row.Username, Email: row.Email, DisplayName: row.DisplayName, - CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, - }) - } - return result, nil -} - -func upsertWorkspaceMember(ctx context.Context, queries *db.Queries, workspaceID, userID uuid.UUID, role string) error { - return queries.UpsertWorkspaceMember(ctx, db.UpsertWorkspaceMemberParams{WorkspaceID: uuidToPG(workspaceID), UserID: uuidToPG(userID), Role: role}) -} - -func deleteWorkspaceMember(ctx context.Context, queries *db.Queries, workspaceID, userID uuid.UUID) error { - rows, err := queries.DeleteWorkspaceMember(ctx, db.DeleteWorkspaceMemberParams{WorkspaceID: uuidToPG(workspaceID), UserID: uuidToPG(userID)}) - if err != nil { - return err - } - if rows == 0 { - return repository.ErrNotFound - } - return nil -} - -func createWorkspaceInvitation(ctx context.Context, queries *db.Queries, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) { - row, err := queries.CreateWorkspaceInvitation(ctx, db.CreateWorkspaceInvitationParams{ - ID: uuidToPG(invitation.ID), WorkspaceID: uuidToPG(invitation.WorkspaceID), - EmailNormalized: invitation.Email, Role: invitation.Role, TokenHash: tokenHash, - InvitedBy: uuidToPG(invitation.InvitedBy), ExpiresAt: pgtype.Timestamptz{Time: invitation.ExpiresAt, Valid: true}, - }) - if err != nil { - return model.WorkspaceInvitation{}, mapMetadataError(err) - } - return invitationFromDB(row), nil -} - -func listWorkspaceInvitations(ctx context.Context, queries *db.Queries, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) { - rows, err := queries.ListWorkspaceInvitations(ctx, uuidToPG(workspaceID)) - if err != nil { - return nil, err - } - result := make([]model.WorkspaceInvitation, 0, len(rows)) - for _, row := range rows { - result = append(result, invitationFromDB(row)) - } - return result, nil -} - -func getWorkspaceInvitationForUpdate(ctx context.Context, queries *db.Queries, tokenHash []byte) (model.WorkspaceInvitation, error) { - row, err := queries.GetWorkspaceInvitationForUpdate(ctx, tokenHash) - if errors.Is(err, pgx.ErrNoRows) { - return model.WorkspaceInvitation{}, repository.ErrNotFound - } - if err != nil { - return model.WorkspaceInvitation{}, err - } - return invitationFromDB(row), nil -} - -func acceptWorkspaceInvitation(ctx context.Context, queries *db.Queries, invitationID, userID uuid.UUID) error { - rows, err := queries.AcceptWorkspaceInvitation(ctx, db.AcceptWorkspaceInvitationParams{ID: uuidToPG(invitationID), AcceptedBy: uuidToPG(userID)}) - if err != nil { - return err - } - if rows == 0 { - return repository.ErrConflict - } - return nil -} - -func revokeWorkspaceInvitation(ctx context.Context, queries *db.Queries, invitationID uuid.UUID) error { - rows, err := queries.RevokeWorkspaceInvitation(ctx, uuidToPG(invitationID)) - if err != nil { - return err - } - if rows == 0 { - return repository.ErrConflict - } - return nil -} - -func appendAccountAudit(ctx context.Context, queries *db.Queries, entry model.AccountAuditLog) error { - metadata, err := json.Marshal(entry.Metadata) - if err != nil { - return err - } - actor := pgtype.UUID{} - if entry.ActorUserID != nil { - actor = uuidToPG(*entry.ActorUserID) - } - return queries.AppendAccountAudit(ctx, db.AppendAccountAuditParams{ - ID: uuidToPG(entry.ID), UserID: uuidToPG(entry.UserID), ActorUserID: actor, - Action: entry.Action, Result: entry.Result, Provider: entry.Provider, - RequestID: entry.RequestID, Metadata: metadata, - OccurredAt: pgtype.Timestamptz{Time: entry.OccurredAt, Valid: true}, - }) -} - -func userFromDB(row db.User) model.User { - return model.User{ - ID: uuidFromPG(row.ID), Issuer: row.Issuer, ExternalSubject: row.ExternalSubject, - Username: row.Username, Email: row.Email, DisplayName: row.DisplayName, - Status: row.Status, TermsVersion: row.TermsVersion, PrivacyVersion: row.PrivacyVersion, - ActivatedAt: timeFromPG(row.ActivatedAt), SuspendedAt: timeFromPG(row.SuspendedAt), - LastSeenAt: timeFromPG(row.LastSeenAt), AgreementsAcceptedAt: timeFromPG(row.AgreementsAcceptedAt), - DeletedAt: timeFromPG(row.DeletedAt), - CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, - } -} - -func invitationFromDB(row db.WorkspaceInvitation) model.WorkspaceInvitation { - var acceptedBy *uuid.UUID - if row.AcceptedBy.Valid { - value := uuidFromPG(row.AcceptedBy) - acceptedBy = &value - } - return model.WorkspaceInvitation{ - ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Email: row.EmailNormalized, - Role: row.Role, InvitedBy: uuidFromPG(row.InvitedBy), ExpiresAt: row.ExpiresAt.Time, - AcceptedAt: timeFromPG(row.AcceptedAt), AcceptedBy: acceptedBy, RevokedAt: timeFromPG(row.RevokedAt), - CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, - } -} - -func timeFromPG(value pgtype.Timestamptz) *time.Time { - if !value.Valid { - return nil - } - result := value.Time - return &result -} - -func workspaceFromDB(row db.Workspace, role string) model.Workspace { - return model.Workspace{ - ID: uuidFromPG(row.ID), Name: row.Name, Slug: row.Slug, Kind: row.Kind, - CreatedBy: uuidFromPG(row.CreatedBy), Role: role, - CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, - } -} - -func uuidToPG(value uuid.UUID) pgtype.UUID { - return pgtype.UUID{Bytes: value, Valid: true} -} - -func nullableUUIDToPG(value uuid.UUID) pgtype.UUID { - if value == uuid.Nil { - return pgtype.UUID{} - } - return uuidToPG(value) -} - -func uuidFromPG(value pgtype.UUID) uuid.UUID { - if !value.Valid { - return uuid.Nil - } - return uuid.UUID(value.Bytes) -} diff --git a/internal/repository/postgres/integration_test.go b/internal/repository/postgres/integration_test.go deleted file mode 100644 index bcb187d..0000000 --- a/internal/repository/postgres/integration_test.go +++ /dev/null @@ -1,691 +0,0 @@ -//go:build integration - -package postgres - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "sync" - "testing" - "time" - - "github.com/google/uuid" - channelmanager "github.com/mooncode-ai/mooncode/internal/channel" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/jobs" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/secretstore" - "github.com/mooncode-ai/mooncode/internal/service" - channelcore "github.com/mooncode-ai/mooncode/pkg/channel" - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" -) - -func TestPostgreSQLIntegration(t *testing.T) { - store := startPostgreSQL(t) - - t.Run("empty database migrates through current version", func(t *testing.T) { - var version int - var dirty bool - require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT version, dirty FROM schema_migrations").Scan(&version, &dirty)) - require.Equal(t, 1, version) - require.False(t, dirty) - }) - - t.Run("workspace scoped repository queries", func(t *testing.T) { - resetPostgreSQL(t, store) - _, firstWorkspace := seedTenant(t, store, "first") - _, secondWorkspace := seedTenant(t, store, "second") - first := integrationCreateRepository(t, store, firstWorkspace, "https://example.com/first.git") - _ = integrationCreateRepository(t, store, secondWorkspace, "https://example.com/second.git") - - _, err := store.GetRepository(t.Context(), secondWorkspace, first.ID) - require.ErrorIs(t, err, repository.ErrNotFound) - items, err := store.ListRepositories(t.Context(), firstWorkspace) - require.NoError(t, err) - require.Len(t, items, 1) - require.Equal(t, first.ID, items[0].ID) - }) - - t.Run("workspace scope covers connections jobs channels and messages", func(t *testing.T) { - resetPostgreSQL(t, store) - userID, firstWorkspace := seedTenant(t, store, "scope-first") - _, secondWorkspace := seedTenant(t, store, "scope-second") - connection, err := store.CreateSCMConnection(t.Context(), model.SCMConnection{ID: uuid.New(), WorkspaceID: firstWorkspace, Type: "generic", Name: "private", BaseURL: "https://git.example.com", AuthType: "token"}) - require.NoError(t, err) - repositoryValue := integrationCreateRepository(t, store, firstWorkspace, "https://git.example.com/repository.git") - job := integrationCreateJob(t, store, firstWorkspace, "scope-job", "test.job", json.RawMessage(`{}`)) - instance := seedChannel(t, store, firstWorkspace) - inserted, err := store.AcceptInbound(t.Context(), instance, channelcore.InboundMessage{ - ExternalEventID: "scope-event", ExternalMessageID: "scope-message", - Conversation: channelcore.ConversationRef{ID: "scope-conversation", Type: "direct"}, - Sender: channelcore.SenderInfo{PlatformID: "scope-sender", CanonicalID: "fake:scope-sender"}, - Content: channelcore.MessageContent{Type: "text", Text: "tenant private"}, OccurredAt: time.Now().UTC(), - }) - require.NoError(t, err) - require.True(t, inserted) - require.NoError(t, store.AppendAudit(t.Context(), firstWorkspace, userID, "scope.test", "repository", repositoryValue.ID, []byte(`{}`))) - - _, err = store.GetSCMConnection(t.Context(), secondWorkspace, connection.ID) - require.ErrorIs(t, err, repository.ErrNotFound) - connections, err := store.ListSCMConnections(t.Context(), secondWorkspace) - require.NoError(t, err) - require.Empty(t, connections) - _, err = store.GetJob(t.Context(), secondWorkspace, job.ID) - require.ErrorIs(t, err, repository.ErrNotFound) - _, err = store.GetChannel(t.Context(), secondWorkspace, instance.ID) - require.ErrorIs(t, err, repository.ErrNotFound) - channels, err := store.ListChannels(t.Context(), secondWorkspace) - require.NoError(t, err) - require.Empty(t, channels) - messages, err := store.ListMessages(t.Context(), secondWorkspace, nil, nil, repository.MessageCursor{}, 10) - require.NoError(t, err) - require.Empty(t, messages) - conversations, err := store.ListConversations(t.Context(), secondWorkspace, 10) - require.NoError(t, err) - require.Empty(t, conversations) - logs, err := store.ListAuditLogs(t.Context(), secondWorkspace, repository.TimeCursor{}, 10) - require.NoError(t, err) - require.Empty(t, logs) - }) - - t.Run("job claim skip locked and lease fencing", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "jobs") - job := integrationCreateJob(t, store, workspaceID, "claim-once", "test.job", json.RawMessage(`{}`)) - - claimed := concurrentlyClaimJobs(t, store, 8, time.Now().Add(time.Minute)) - require.Len(t, claimed, 1) - require.Equal(t, job.ID, claimed[0].ID) - - resetPostgreSQL(t, store) - _, workspaceID = seedTenant(t, store, "takeover") - job = integrationCreateJob(t, store, workspaceID, "take-over", "test.job", json.RawMessage(`{}`)) - first, err := store.ClaimJob(t.Context(), []string{"test.job"}, "backend-one", time.Now().Add(-time.Second)) - require.NoError(t, err) - second, err := store.ClaimJob(t.Context(), []string{"test.job"}, "backend-two", time.Now().Add(time.Minute)) - require.NoError(t, err) - require.Equal(t, job.ID, second.ID) - require.Greater(t, second.FencingToken, first.FencingToken) - require.ErrorIs(t, store.CompleteJob(t.Context(), first), repository.ErrLeaseLost) - require.NoError(t, store.CompleteJob(t.Context(), second)) - }) - - t.Run("job runner restart reclaims an expired attempt", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "runner-restart") - job := integrationCreateJob(t, store, workspaceID, "runner-restart", "test.restart", json.RawMessage(`{}`)) - cfg, err := config.NewLoader().Load("", nil) - require.NoError(t, err) - cfg.Jobs.PollInterval = 10 * time.Millisecond - cfg.Jobs.RenewInterval = 50 * time.Millisecond - cfg.Jobs.LeaseDuration = time.Second - started := make(chan struct{}) - first, err := jobs.NewRunner(store, "backend-before-restart", cfg, zerolog.New(io.Discard), jobs.WithHandler(&integrationJobHandler{jobType: "test.restart", started: started, waitForCancel: true})) - require.NoError(t, err) - firstContext, stopFirst := context.WithCancel(t.Context()) - firstDone := make(chan error, 1) - go func() { firstDone <- first.Run(firstContext) }() - select { - case <-started: - case <-time.After(5 * time.Second): - t.Fatal("first runner did not claim the job") - } - stopFirst() - require.NoError(t, <-firstDone) - _, err = store.Pool().Exec(t.Context(), "UPDATE jobs SET lease_until = now() - interval '1 second' WHERE id = $1", job.ID) - require.NoError(t, err) - - second, err := jobs.NewRunner(store, "backend-after-restart", cfg, zerolog.New(io.Discard), jobs.WithHandler(&integrationJobHandler{jobType: "test.restart"})) - require.NoError(t, err) - secondContext, stopSecond := context.WithCancel(t.Context()) - secondDone := make(chan error, 1) - go func() { secondDone <- second.Run(secondContext) }() - require.Eventually(t, func() bool { - current, getErr := store.GetJob(t.Context(), workspaceID, job.ID) - return getErr == nil && current.Status == "succeeded" && current.Attempt == 2 - }, 5*time.Second, 20*time.Millisecond) - stopSecond() - require.NoError(t, <-secondDone) - }) - - t.Run("repository sync completion is fenced and records the current commit", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "sync-retry") - repositoryValue := integrationCreateRepository(t, store, workspaceID, "https://example.com/retry.git") - payload, err := json.Marshal(model.RepositorySyncPayload{RepositoryID: repositoryValue.ID}) - require.NoError(t, err) - _ = integrationCreateJob(t, store, workspaceID, "sync-retry", "repository.sync", payload) - first, err := store.ClaimJob(t.Context(), []string{"repository.sync"}, "backend-one", time.Now().Add(time.Minute)) - require.NoError(t, err) - require.NoError(t, store.MarkRepositorySyncing(t.Context(), first, repositoryValue.ID)) - - _, err = store.Pool().Exec(t.Context(), "UPDATE jobs SET lease_until = now() - interval '1 second' WHERE id = $1", first.ID) - require.NoError(t, err) - second, err := store.ClaimJob(t.Context(), []string{"repository.sync"}, "backend-two", time.Now().Add(time.Minute)) - require.NoError(t, err) - require.ErrorIs(t, store.MarkSyncReady(t.Context(), first, repositoryValue.ID, "old"), repository.ErrLeaseLost) - commitSHA := "0123456789abcdef0123456789abcdef01234567" - require.NoError(t, store.MarkSyncReady(t.Context(), second, repositoryValue.ID, commitSHA)) - - current, err := store.GetRepository(t.Context(), workspaceID, repositoryValue.ID) - require.NoError(t, err) - require.Equal(t, "ready", current.State) - require.Equal(t, commitSHA, current.CurrentCommitSHA) - require.NotNil(t, current.SyncedAt) - }) - - t.Run("job cancellation and repository soft deletion fence active work", func(t *testing.T) { - resetPostgreSQL(t, store) - userID, workspaceID := seedTenant(t, store, "delete") - repositoryValue := integrationCreateRepository(t, store, workspaceID, "https://example.com/delete.git") - payload, err := json.Marshal(model.RepositorySyncPayload{RepositoryID: repositoryValue.ID}) - require.NoError(t, err) - _ = integrationCreateJob(t, store, workspaceID, "delete-active", "repository.sync", payload) - claimed, err := store.ClaimJob(t.Context(), []string{"repository.sync"}, "backend-delete", time.Now().Add(time.Minute)) - require.NoError(t, err) - require.NoError(t, store.MarkRepositorySyncing(t.Context(), claimed, repositoryValue.ID)) - - err = store.WithinMetadataTx(t.Context(), func(tx repository.MetadataStore) error { - cancelled, txErr := tx.CancelActiveRepositorySyncJob(t.Context(), workspaceID, repositoryValue.ID) - if txErr != nil { - return txErr - } - if cancelled.Status != "cancelled" { - return fmt.Errorf("cancelled status = %s", cancelled.Status) - } - if txErr = tx.CancelRepositorySyncJobs(t.Context(), workspaceID, repositoryValue.ID); txErr != nil { - return txErr - } - if _, txErr = tx.SoftDeleteRepository(t.Context(), workspaceID, repositoryValue.ID); txErr != nil { - return txErr - } - return tx.AppendAudit(t.Context(), workspaceID, userID, "repository.delete", "repository", repositoryValue.ID, []byte(`{}`)) - }) - require.NoError(t, err) - require.ErrorIs(t, store.MarkRepositorySyncing(t.Context(), claimed, repositoryValue.ID), repository.ErrLeaseLost) - _, err = store.GetRepository(t.Context(), workspaceID, repositoryValue.ID) - require.ErrorIs(t, err, repository.ErrNotFound) - var status string - require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT status FROM jobs WHERE id = $1", claimed.ID).Scan(&status)) - require.Equal(t, "cancelled", status) - logs, err := store.ListAuditLogs(t.Context(), workspaceID, repository.TimeCursor{}, 10) - require.NoError(t, err) - require.Len(t, logs, 1) - require.Equal(t, "repository.delete", logs[0].Action) - }) - - t.Run("channel lease takeover fences mutable status", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "channels") - instance := seedChannel(t, store, workspaceID) - first, err := store.AcquireChannelLease(t.Context(), instance.ID, "backend-one", time.Now().Add(time.Minute)) - require.NoError(t, err) - _, err = store.AcquireChannelLease(t.Context(), instance.ID, "backend-two", time.Now().Add(time.Minute)) - require.Error(t, err) - - _, err = store.Pool().Exec(t.Context(), "UPDATE channel_leases SET lease_until = now() - interval '1 second' WHERE channel_instance_id = $1", instance.ID) - require.NoError(t, err) - second, err := store.AcquireChannelLease(t.Context(), instance.ID, "backend-two", time.Now().Add(time.Minute)) - require.NoError(t, err) - require.Greater(t, second.FencingToken, first.FencingToken) - require.ErrorIs(t, store.RenewChannelLease(t.Context(), first, time.Now().Add(time.Minute)), repository.ErrLeaseLost) - require.ErrorIs(t, store.SetChannelStatus(t.Context(), model.ChannelRuntimeStatus{ChannelInstanceID: instance.ID, State: "running", BackendInstanceID: first.Owner, FencingToken: first.FencingToken}), repository.ErrLeaseLost) - require.NoError(t, store.SetChannelStatus(t.Context(), model.ChannelRuntimeStatus{ChannelInstanceID: instance.ID, State: "running", BackendInstanceID: second.Owner, FencingToken: second.FencingToken})) - }) - - t.Run("inbox duplicate and outbox are atomic", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "inbox") - instance := seedChannel(t, store, workspaceID) - message := channelcore.InboundMessage{ - ExternalEventID: "event-1", ExternalMessageID: "message-1", - Conversation: channelcore.ConversationRef{ID: "chat-1", Type: "group"}, - Sender: channelcore.SenderInfo{PlatformID: "sender-1", CanonicalID: "feishu:sender-1", DisplayName: "Sender"}, - Content: channelcore.MessageContent{Type: "text", Text: "hello"}, OccurredAt: time.Now().UTC(), - } - inserted, err := store.AcceptInbound(t.Context(), instance, message) - require.NoError(t, err) - require.True(t, inserted) - inserted, err = store.AcceptInbound(t.Context(), instance, message) - require.NoError(t, err) - require.False(t, inserted) - for table, expected := range map[string]int{"inbox_events": 1, "im_messages": 1, "outbox_events": 1} { - var count int - require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM "+table).Scan(&count)) - require.Equal(t, expected, count, table) - } - }) - - t.Run("fake channel factory reaches the persisted inbox", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "fake-channel") - instance := seedChannel(t, store, workspaceID) - _, err := store.SetChannelEnabled(t.Context(), workspaceID, instance.ID, true) - require.NoError(t, err) - registry, err := channelcore.NewRegistry(channelcore.WithFactory(&integrationChannelFactory{})) - require.NoError(t, err) - cfg, err := config.NewLoader().Load("", nil) - require.NoError(t, err) - manager := channelmanager.NewManager(store, noSecretStore{}, registry, "backend-e2e", cfg, zerolog.New(io.Discard)) - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { done <- manager.Run(ctx) }() - require.Eventually(t, func() bool { - messages, listErr := store.ListMessages(t.Context(), workspaceID, &instance.ID, nil, repository.MessageCursor{}, 10) - return listErr == nil && len(messages) == 1 && messages[0].ExternalMessageID == "fake-message" - }, 5*time.Second, 20*time.Millisecond) - cancel() - require.NoError(t, <-done) - - restarted := channelmanager.NewManager(store, noSecretStore{}, registry, "backend-e2e-restarted", cfg, zerolog.New(io.Discard)) - restartContext, stopRestart := context.WithCancel(t.Context()) - restartDone := make(chan error, 1) - go func() { restartDone <- restarted.Run(restartContext) }() - require.Eventually(t, func() bool { - messages, listErr := store.ListMessages(t.Context(), workspaceID, &instance.ID, nil, repository.MessageCursor{}, 10) - status, statusErr := store.GetChannelStatus(t.Context(), instance.ID) - return listErr == nil && len(messages) == 1 && statusErr == nil && status.BackendInstanceID == "backend-e2e-restarted" - }, 5*time.Second, 20*time.Millisecond) - stopRestart() - require.NoError(t, <-restartDone) - }) - - t.Run("cooldown and active job uniqueness", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "cooldown") - value := integrationCreateRepository(t, store, workspaceID, "https://example.com/cooldown.git") - require.NoError(t, store.ReserveRepositorySync(t.Context(), workspaceID, value.ID, time.Now().Add(time.Minute))) - require.ErrorIs(t, store.ReserveRepositorySync(t.Context(), workspaceID, value.ID, time.Now().Add(time.Minute)), repository.ErrConflict) - - payload, err := json.Marshal(model.RepositorySyncPayload{RepositoryID: value.ID}) - require.NoError(t, err) - _ = integrationCreateJob(t, store, workspaceID, "active-one", "repository.sync", payload) - _, err = store.CreateJob(t.Context(), model.Job{ID: uuid.New(), WorkspaceID: workspaceID, Type: "repository.sync", Payload: payload, MaxAttempts: 5, RunAfter: time.Now()}, "active-two") - require.ErrorIs(t, err, repository.ErrConflict) - }) - - t.Run("mutation gate is cross-replica safe and idempotent", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "mutations") - now := time.Now().UTC() - request := repository.MutationRequest{ - WorkspaceID: workspaceID, Operation: "channel.enable", ResourceKey: uuid.NewString(), - IdempotencyKey: "request-one", RequestHash: "hash-one", ResourceID: uuid.New(), - ActiveUntil: now.Add(time.Minute), NextAllowedAt: now.Add(time.Minute), - } - claim, err := store.BeginMutation(t.Context(), request) - require.NoError(t, err) - require.False(t, claim.Replay) - - contender := request - contender.IdempotencyKey = "request-two" - contender.RequestHash = "hash-two" - _, err = store.BeginMutation(t.Context(), contender) - require.ErrorIs(t, err, repository.ErrConflict) - - require.NoError(t, store.FinishMutation(t.Context(), request, true)) - claim, err = store.BeginMutation(t.Context(), request) - require.NoError(t, err) - require.True(t, claim.Replay) - require.Equal(t, request.ResourceID, claim.ResourceID) - - mismatched := request - mismatched.RequestHash = "changed-body" - _, err = store.BeginMutation(t.Context(), mismatched) - require.ErrorIs(t, err, repository.ErrConflict) - }) - - t.Run("outbox claims skip locked", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "dispatch") - outboxID := uuid.New() - _, err := store.Pool().Exec(t.Context(), `INSERT INTO outbox_events (id, workspace_id, aggregate, aggregate_id, event_type, payload) VALUES ($1,$2,'test',$3,'test.created','{}')`, outboxID, workspaceID, uuid.New()) - require.NoError(t, err) - require.Equal(t, 1, concurrentlyClaimOutbox(t, store, 8)) - }) - - t.Run("business write and audit roll back together", func(t *testing.T) { - resetPostgreSQL(t, store) - userID, workspaceID := seedTenant(t, store, "audit") - repositoryID := uuid.New() - sentinel := errors.New("force rollback") - err := store.WithinMetadataTx(t.Context(), func(tx repository.MetadataStore) error { - _, err := tx.CreateRepository(t.Context(), model.Repository{ID: repositoryID, WorkspaceID: workspaceID, Name: "rollback", CloneURL: "https://example.com/rollback.git", NormalizedURL: "https://example.com/rollback.git"}) - if err != nil { - return err - } - if err := tx.AppendAudit(t.Context(), workspaceID, userID, "repository.create", "repository", repositoryID, []byte(`{}`)); err != nil { - return err - } - return sentinel - }) - require.ErrorIs(t, err, sentinel) - _, err = store.GetRepository(t.Context(), workspaceID, repositoryID) - require.ErrorIs(t, err, repository.ErrNotFound) - var auditCount int - require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM audit_logs").Scan(&auditCount)) - require.Zero(t, auditCount) - }) - - t.Run("soft deletion releases secret references", func(t *testing.T) { - resetPostgreSQL(t, store) - _, workspaceID := seedTenant(t, store, "secret-release") - connectionSecret := seedSecretRecord(t, store, workspaceID, "scm_connection") - connection, err := store.CreateSCMConnection(t.Context(), model.SCMConnection{ID: uuid.New(), WorkspaceID: workspaceID, Type: "generic", Name: "secret connection", BaseURL: "https://git.example.com", AuthType: "token", SecretRef: &connectionSecret}) - require.NoError(t, err) - _, err = store.SoftDeleteSCMConnection(t.Context(), workspaceID, connection.ID) - require.NoError(t, err) - require.NoError(t, store.DeleteSecretRecord(t.Context(), connectionSecret, workspaceID)) - - channelSecret := seedSecretRecord(t, store, workspaceID, "channel_instance") - instance, err := store.CreateChannel(t.Context(), model.ChannelInstance{ID: uuid.New(), WorkspaceID: workspaceID, Type: "fake", Name: "secret channel", SecretRef: &channelSecret, Config: model.ChannelConfig{Values: map[string]any{}}}) - require.NoError(t, err) - _, err = store.SoftDeleteChannel(t.Context(), workspaceID, instance.ID) - require.NoError(t, err) - require.NoError(t, store.DeleteSecretRecord(t.Context(), channelSecret, workspaceID)) - - var count int - require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM secrets").Scan(&count)) - require.Zero(t, count) - }) - - t.Run("failed mutation audit persists with nullable resource", func(t *testing.T) { - resetPostgreSQL(t, store) - userID, workspaceID := seedTenant(t, store, "failure-audit") - identities := service.NewIdentityService(store, config.Config{}) - workspaces := service.NewWorkspaceService(store, identities) - - _, err := workspaces.Update(t.Context(), model.Principal{User: model.User{ID: userID, Status: "active"}}, workspaceID, "") - require.Error(t, err) - - logs, err := store.ListAuditLogs(t.Context(), workspaceID, repository.TimeCursor{}, 10) - require.NoError(t, err) - require.Len(t, logs, 1) - require.Equal(t, "workspace.update", logs[0].Action) - require.Equal(t, "failure", logs[0].Result) - require.NotNil(t, logs[0].ResourceID) - require.Equal(t, workspaceID, *logs[0].ResourceID) - - require.NoError(t, store.AppendAuditResult(t.Context(), workspaceID, userID, "repository.create", "repository", uuid.Nil, "failure", []byte(`{"code":"operation_failed"}`))) - var resourceID *uuid.UUID - require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT resource_id FROM audit_logs WHERE action = 'repository.create'").Scan(&resourceID)) - require.Nil(t, resourceID) - }) - - t.Run("oauth registration and invitation flow is transactional", func(t *testing.T) { - resetPostgreSQL(t, store) - cfg, err := config.NewLoader().Load("", map[string]any{ - "registration.mode": "public", - "identity.issuer": "https://github.com", - }) - require.NoError(t, err) - identities := service.NewIdentityService(store, cfg) - registrations := service.NewRegistrationService(store, identities, cfg) - owner, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "100", Username: "owner", Email: "owner@example.com"}) - require.NoError(t, err) - ownerSession, err := registrations.Complete(t.Context(), owner, service.CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true}) - require.NoError(t, err) - require.Equal(t, "active", ownerSession.User.Status) - require.Len(t, ownerSession.Workspaces, 1) - - workspaces := service.NewWorkspaceService(store, identities) - team, err := workspaces.Create(t.Context(), model.Principal{User: ownerSession.User}, "Engineering") - require.NoError(t, err) - created, err := registrations.CreateInvitation(t.Context(), model.Principal{User: ownerSession.User}, team.ID, service.CreateInvitationInput{Email: "member@example.com", Role: "member"}) - require.NoError(t, err) - require.NotEmpty(t, created.Token) - var storedTokenHash []byte - require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT token_hash FROM workspace_invitations WHERE id = $1", created.ID).Scan(&storedTokenHash)) - require.Len(t, storedTokenHash, 32) - require.NotEqual(t, []byte(created.Token), storedTokenHash) - - cfg.Registration.Mode = "invite_only" - inviteIdentities := service.NewIdentityService(store, cfg) - inviteRegistrations := service.NewRegistrationService(store, inviteIdentities, cfg) - member, err := inviteIdentities.Resolve(t.Context(), model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "200", Username: "member", Email: "member@example.com"}) - require.NoError(t, err) - memberSession, err := inviteRegistrations.AcceptInvitation(t.Context(), member, service.AcceptInvitationInput{Token: created.Token, AcceptTerms: true, AcceptPrivacy: true}) - require.NoError(t, err) - require.Equal(t, "active", memberSession.User.Status) - require.Len(t, memberSession.Workspaces, 2) - - secondTeam, err := workspaces.Create(t.Context(), model.Principal{User: ownerSession.User}, "Platform") - require.NoError(t, err) - activeInvitation, err := inviteRegistrations.CreateInvitation(t.Context(), model.Principal{User: ownerSession.User}, secondTeam.ID, service.CreateInvitationInput{Email: "member@example.com", Role: "member"}) - require.NoError(t, err) - memberSession, err = inviteRegistrations.AcceptInvitation(t.Context(), model.Principal{User: memberSession.User}, service.AcceptInvitationInput{Token: activeInvitation.Token, AcceptTerms: true, AcceptPrivacy: true}) - require.NoError(t, err) - require.Len(t, memberSession.Workspaces, 3) - - conflictingInvitation, err := inviteRegistrations.CreateInvitation(t.Context(), model.Principal{User: ownerSession.User}, secondTeam.ID, service.CreateInvitationInput{Email: "member@example.com", Role: "viewer"}) - require.NoError(t, err) - _, err = inviteRegistrations.AcceptInvitation(t.Context(), model.Principal{User: memberSession.User}, service.AcceptInvitationInput{Token: conflictingInvitation.Token, AcceptTerms: true, AcceptPrivacy: true}) - require.ErrorIs(t, err, service.ErrInvitationInvalid) - membership, err := identities.Workspace(t.Context(), model.Principal{User: memberSession.User}, secondTeam.ID) - require.NoError(t, err) - require.Equal(t, "member", membership.Role) - - var accepted, audits int - require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM workspace_invitations WHERE accepted_by = $1", memberSession.User.ID).Scan(&accepted)) - require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM account_audit_logs").Scan(&audits)) - require.Equal(t, 2, accepted) - require.Equal(t, 3, audits) - }) -} - -func startPostgreSQL(t *testing.T) *Store { - t.Helper() - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) - defer cancel() - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "postgres:17-bookworm", - Env: map[string]string{"POSTGRES_DB": "mooncode", "POSTGRES_USER": "mooncode", "POSTGRES_PASSWORD": "mooncode"}, - ExposedPorts: []string{"5432/tcp"}, - WaitingFor: wait.ForLog("database system is ready to accept connections").WithOccurrence(2).WithStartupTimeout(90 * time.Second), - }, - Started: true, - }) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) - host, err := container.Host(ctx) - require.NoError(t, err) - port, err := container.MappedPort(ctx, "5432/tcp") - require.NoError(t, err) - - cfg, err := config.NewLoader().Load("", map[string]any{ - "database.url": fmt.Sprintf("postgres://mooncode:mooncode@%s:%s/mooncode?sslmode=disable", host, port.Port()), - "database.auto_migrate": true, - "database.min_connections": 0, - "database.connect_timeout": 30 * time.Second, - }) - require.NoError(t, err) - store, err := NewStore(cfg, zerolog.New(io.Discard), trace.NewNoopTracerProvider(), propagation.TraceContext{}) - require.NoError(t, err) - t.Cleanup(store.Close) - return store -} - -func resetPostgreSQL(t *testing.T, store *Store) { - t.Helper() - _, err := store.Pool().Exec(t.Context(), "TRUNCATE TABLE users CASCADE") - require.NoError(t, err) -} - -func seedTenant(t *testing.T, store *Store, suffix string) (uuid.UUID, uuid.UUID) { - t.Helper() - userID, workspaceID := uuid.New(), uuid.New() - identity := suffix + "-" + userID.String() - _, err := store.Pool().Exec(t.Context(), "INSERT INTO users (id, issuer, external_subject, username, status, activated_at) VALUES ($1,'test',$2,$2,'active',now())", userID, identity) - require.NoError(t, err) - _, err = store.Pool().Exec(t.Context(), "INSERT INTO workspaces (id, name, slug, kind, created_by) VALUES ($1,$2,$2,'team',$3)", workspaceID, identity, userID) - require.NoError(t, err) - _, err = store.Pool().Exec(t.Context(), "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,'owner')", workspaceID, userID) - require.NoError(t, err) - return userID, workspaceID -} - -func integrationCreateRepository(t *testing.T, store *Store, workspaceID uuid.UUID, remote string) model.Repository { - t.Helper() - value, err := store.CreateRepository(t.Context(), model.Repository{ID: uuid.New(), WorkspaceID: workspaceID, Name: "repository", CloneURL: remote, NormalizedURL: remote}) - require.NoError(t, err) - return value -} - -func integrationCreateJob(t *testing.T, store *Store, workspaceID uuid.UUID, key, jobType string, payload json.RawMessage) model.Job { - t.Helper() - value, err := store.CreateJob(t.Context(), model.Job{ID: uuid.New(), WorkspaceID: workspaceID, Type: jobType, Payload: payload, MaxAttempts: 5, RunAfter: time.Now().Add(-time.Second)}, key) - require.NoError(t, err) - return value -} - -func seedChannel(t *testing.T, store *Store, workspaceID uuid.UUID) model.ChannelInstance { - t.Helper() - value, err := store.CreateChannel(t.Context(), model.ChannelInstance{ID: uuid.New(), WorkspaceID: workspaceID, Type: "fake", Name: "fixture", Config: model.ChannelConfig{Values: map[string]any{}}}) - require.NoError(t, err) - return value -} - -func seedSecretRecord(t *testing.T, store *Store, workspaceID uuid.UUID, resourceType string) uuid.UUID { - t.Helper() - id := uuid.New() - require.NoError(t, store.CreateSecretRecord(t.Context(), secretstore.Record{ - ID: id, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: resourceType, ResourceID: uuid.New()}, - Ciphertext: []byte("ciphertext"), Nonce: []byte("nonce"), WrappedKey: []byte("wrapped"), WrappedKeyNonce: []byte("wrapped-nonce"), KeyVersion: 1, - })) - return id -} - -func concurrentlyClaimJobs(t *testing.T, store *Store, count int, until time.Time) []model.Job { - t.Helper() - results := make(chan model.Job, count) - var group sync.WaitGroup - for index := 0; index < count; index++ { - group.Add(1) - go func(index int) { - defer group.Done() - job, err := store.ClaimJob(t.Context(), []string{"test.job"}, fmt.Sprintf("owner-%d", index), until) - if err == nil { - results <- job - return - } - require.ErrorIs(t, err, repository.ErrNotFound) - }(index) - } - group.Wait() - close(results) - return collect(results) -} - -func concurrentlyClaimOutbox(t *testing.T, store *Store, count int) int { - t.Helper() - return concurrentSuccesses(t, count, func(index int) error { - _, err := store.ClaimOutboxEvent(t.Context(), fmt.Sprintf("owner-%d", index), time.Now().Add(time.Minute)) - return err - }) -} - -func concurrentSuccesses(t *testing.T, count int, claim func(int) error) int { - t.Helper() - results := make(chan bool, count) - var group sync.WaitGroup - for index := 0; index < count; index++ { - group.Add(1) - go func(index int) { - defer group.Done() - err := claim(index) - if err != nil { - require.ErrorIs(t, err, repository.ErrNotFound) - } - results <- err == nil - }(index) - } - group.Wait() - close(results) - successes := 0 - for success := range results { - if success { - successes++ - } - } - return successes -} - -func collect(values <-chan model.Job) []model.Job { - result := make([]model.Job, 0) - for value := range values { - result = append(result, value) - } - return result -} - -type integrationChannelFactory struct{} - -func (*integrationChannelFactory) Type() string { return "fake" } -func (*integrationChannelFactory) Descriptor() channelcore.Descriptor { - return channelcore.Descriptor{Type: "fake", DisplayName: "Fake Channel"} -} -func (*integrationChannelFactory) Validate(channelcore.InstanceConfig) error { return nil } -func (*integrationChannelFactory) New(_ channelcore.InstanceConfig, sink channelcore.InboundSink, _ ...channelcore.ChannelOption) (channelcore.Channel, error) { - return &integrationChannelRuntime{sink: sink}, nil -} - -type integrationChannelRuntime struct{ sink channelcore.InboundSink } - -func (*integrationChannelRuntime) Type() string { return "fake" } -func (runtime *integrationChannelRuntime) Run(ctx context.Context) error { - _, err := runtime.sink.Accept(ctx, channelcore.InboundMessage{ - ExternalEventID: "fake-event", ExternalMessageID: "fake-message", - Conversation: channelcore.ConversationRef{ID: "fake-conversation", Type: "direct"}, - Sender: channelcore.SenderInfo{PlatformID: "fake-sender", CanonicalID: "fake:sender"}, - Content: channelcore.MessageContent{Type: "text", Text: "fixture"}, OccurredAt: time.Now().UTC(), - }) - if err != nil { - return err - } - <-ctx.Done() - return nil -} - -type noSecretStore struct{} - -func (noSecretStore) Put(context.Context, secretstore.Scope, secretstore.SecretValues) (secretstore.SecretRef, error) { - return secretstore.SecretRef{}, errors.New("unexpected secret write") -} -func (noSecretStore) Get(context.Context, secretstore.SecretRef) (secretstore.SecretValues, error) { - return nil, errors.New("unexpected secret read") -} -func (noSecretStore) Delete(context.Context, secretstore.SecretRef) error { return nil } - -var _ channelcore.Factory = (*integrationChannelFactory)(nil) -var _ channelcore.Channel = (*integrationChannelRuntime)(nil) -var _ secretstore.SecretStore = noSecretStore{} - -type integrationJobHandler struct { - jobType string - started chan struct{} - waitForCancel bool -} - -func (handler *integrationJobHandler) Type() string { return handler.jobType } -func (handler *integrationJobHandler) Handle(ctx context.Context, _ model.Job) error { - if handler.started != nil { - close(handler.started) - } - if handler.waitForCancel { - <-ctx.Done() - return ctx.Err() - } - return nil -} - -var _ jobs.Handler = (*integrationJobHandler)(nil) diff --git a/internal/repository/postgres/metadata.go b/internal/repository/postgres/metadata.go deleted file mode 100644 index 75eac89..0000000 --- a/internal/repository/postgres/metadata.go +++ /dev/null @@ -1,537 +0,0 @@ -package postgres - -import ( - "context" - "errors" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgtype" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc" - "go.opentelemetry.io/otel/propagation" -) - -type metadataTx struct { - queries *db.Queries - propagator propagation.TextMapPropagator -} - -func (s *Store) WithinMetadataTx(ctx context.Context, fn func(repository.MetadataStore) error) error { - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return err - } - defer func() { _ = tx.Rollback(ctx) }() - if err := fn(&metadataTx{queries: s.queries.WithTx(tx), propagator: s.propagator}); err != nil { - return err - } - return tx.Commit(ctx) -} - -func (t *metadataTx) WithinMetadataTx(_ context.Context, fn func(repository.MetadataStore) error) error { - return fn(t) -} - -func (s *Store) CreateSCMConnection(ctx context.Context, value model.SCMConnection) (model.SCMConnection, error) { - return createSCMConnection(ctx, s.queries, value) -} -func (t *metadataTx) CreateSCMConnection(ctx context.Context, value model.SCMConnection) (model.SCMConnection, error) { - return createSCMConnection(ctx, t.queries, value) -} -func createSCMConnection(ctx context.Context, q *db.Queries, value model.SCMConnection) (model.SCMConnection, error) { - row, err := q.CreateSCMConnection(ctx, db.CreateSCMConnectionParams{ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), Type: value.Type, Name: value.Name, BaseUrl: value.BaseURL, AuthType: value.AuthType, SecretRef: nullableUUID(value.SecretRef)}) - if err != nil { - return model.SCMConnection{}, mapMetadataError(err) - } - return connectionFromDB(row), nil -} - -func (s *Store) ListSCMConnections(ctx context.Context, workspaceID uuid.UUID) ([]model.SCMConnection, error) { - return listSCMConnections(ctx, s.queries, workspaceID) -} -func (t *metadataTx) ListSCMConnections(ctx context.Context, workspaceID uuid.UUID) ([]model.SCMConnection, error) { - return listSCMConnections(ctx, t.queries, workspaceID) -} -func listSCMConnections(ctx context.Context, q *db.Queries, workspaceID uuid.UUID) ([]model.SCMConnection, error) { - rows, err := q.ListSCMConnections(ctx, uuidToPG(workspaceID)) - if err != nil { - return nil, err - } - result := make([]model.SCMConnection, 0, len(rows)) - for _, row := range rows { - result = append(result, connectionFromDB(row)) - } - return result, nil -} - -func (s *Store) GetSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) { - return getSCMConnection(ctx, s.queries, workspaceID, id) -} -func (t *metadataTx) GetSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) { - return getSCMConnection(ctx, t.queries, workspaceID, id) -} -func getSCMConnection(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.SCMConnection, error) { - row, err := q.GetSCMConnection(ctx, db.GetSCMConnectionParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - if err != nil { - return model.SCMConnection{}, mapMetadataError(err) - } - return connectionFromDB(row), nil -} - -func (s *Store) UpdateSCMConnection(ctx context.Context, value model.SCMConnection) (model.SCMConnection, error) { - return updateSCMConnection(ctx, s.queries, value) -} -func (t *metadataTx) UpdateSCMConnection(ctx context.Context, value model.SCMConnection) (model.SCMConnection, error) { - return updateSCMConnection(ctx, t.queries, value) -} -func updateSCMConnection(ctx context.Context, q *db.Queries, value model.SCMConnection) (model.SCMConnection, error) { - row, err := q.UpdateSCMConnection(ctx, db.UpdateSCMConnectionParams{ - Name: value.Name, BaseUrl: value.BaseURL, AuthType: value.AuthType, SecretRef: nullableUUID(value.SecretRef), - ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), - }) - if err != nil { - return model.SCMConnection{}, mapMetadataError(err) - } - return connectionFromDB(row), nil -} - -func (s *Store) SoftDeleteSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) { - return softDeleteSCMConnection(ctx, s.queries, workspaceID, id) -} -func (t *metadataTx) SoftDeleteSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) { - return softDeleteSCMConnection(ctx, t.queries, workspaceID, id) -} -func softDeleteSCMConnection(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.SCMConnection, error) { - row, err := q.SoftDeleteSCMConnection(ctx, db.SoftDeleteSCMConnectionParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - if err != nil { - return model.SCMConnection{}, mapMetadataError(err) - } - return connectionFromDB(row), nil -} - -func (s *Store) CreateRepository(ctx context.Context, value model.Repository) (model.Repository, error) { - return createRepository(ctx, s.queries, value) -} -func (t *metadataTx) CreateRepository(ctx context.Context, value model.Repository) (model.Repository, error) { - return createRepository(ctx, t.queries, value) -} -func createRepository(ctx context.Context, q *db.Queries, value model.Repository) (model.Repository, error) { - row, err := q.CreateRepository(ctx, db.CreateRepositoryParams{ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), ConnectionID: nullableUUID(value.ConnectionID), Name: value.Name, CloneUrl: value.CloneURL, NormalizedUrl: value.NormalizedURL, Ref: value.Ref}) - if err != nil { - return model.Repository{}, mapMetadataError(err) - } - return repositoryFromDB(row), nil -} - -func (s *Store) ListRepositories(ctx context.Context, workspaceID uuid.UUID) ([]model.Repository, error) { - return listRepositories(ctx, s.queries, workspaceID) -} -func (t *metadataTx) ListRepositories(ctx context.Context, workspaceID uuid.UUID) ([]model.Repository, error) { - return listRepositories(ctx, t.queries, workspaceID) -} -func listRepositories(ctx context.Context, q *db.Queries, workspaceID uuid.UUID) ([]model.Repository, error) { - rows, err := q.ListRepositories(ctx, uuidToPG(workspaceID)) - if err != nil { - return nil, err - } - result := make([]model.Repository, 0, len(rows)) - for _, row := range rows { - result = append(result, repositoryFromDB(row)) - } - return result, nil -} - -func (s *Store) GetRepository(ctx context.Context, workspaceID, id uuid.UUID) (model.Repository, error) { - return getRepository(ctx, s.queries, workspaceID, id) -} -func (t *metadataTx) GetRepository(ctx context.Context, workspaceID, id uuid.UUID) (model.Repository, error) { - return getRepository(ctx, t.queries, workspaceID, id) -} -func getRepository(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.Repository, error) { - row, err := q.GetRepository(ctx, db.GetRepositoryParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - if err != nil { - return model.Repository{}, mapMetadataError(err) - } - return repositoryFromDB(row), nil -} - -func (s *Store) SetRepositoryRef(ctx context.Context, workspaceID, id uuid.UUID, ref string) error { - return setRepositoryRef(ctx, s.queries, workspaceID, id, ref) -} -func (t *metadataTx) SetRepositoryRef(ctx context.Context, workspaceID, id uuid.UUID, ref string) error { - return setRepositoryRef(ctx, t.queries, workspaceID, id, ref) -} -func setRepositoryRef(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID, ref string) error { - rows, err := q.SetRepositoryRef(ctx, db.SetRepositoryRefParams{Ref: ref, ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - return requireAffected(rows, err) -} - -func (s *Store) CancelRepositorySyncJobs(ctx context.Context, workspaceID, repositoryID uuid.UUID) error { - return s.queries.CancelRepositorySyncJobs(ctx, db.CancelRepositorySyncJobsParams{WorkspaceID: uuidToPG(workspaceID), RepositoryID: repositoryID.String()}) -} -func (t *metadataTx) CancelRepositorySyncJobs(ctx context.Context, workspaceID, repositoryID uuid.UUID) error { - return t.queries.CancelRepositorySyncJobs(ctx, db.CancelRepositorySyncJobsParams{WorkspaceID: uuidToPG(workspaceID), RepositoryID: repositoryID.String()}) -} -func (s *Store) SoftDeleteRepository(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Repository, error) { - return softDeleteRepository(ctx, s.queries, workspaceID, repositoryID) -} -func (t *metadataTx) SoftDeleteRepository(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Repository, error) { - return softDeleteRepository(ctx, t.queries, workspaceID, repositoryID) -} -func softDeleteRepository(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID) (model.Repository, error) { - row, err := q.SoftDeleteRepository(ctx, db.SoftDeleteRepositoryParams{ID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID)}) - if err != nil { - return model.Repository{}, mapMetadataError(err) - } - return repositoryFromDB(row), nil -} -func (s *Store) ReserveRepositorySync(ctx context.Context, workspaceID, repositoryID uuid.UUID, next time.Time) error { - return reserveSync(ctx, s.queries, workspaceID, repositoryID, next) -} -func (t *metadataTx) ReserveRepositorySync(ctx context.Context, workspaceID, repositoryID uuid.UUID, next time.Time) error { - return reserveSync(ctx, t.queries, workspaceID, repositoryID, next) -} -func reserveSync(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID, next time.Time) error { - _, err := q.ReserveRepositorySyncCooldown(ctx, db.ReserveRepositorySyncCooldownParams{RepositoryID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID), NextAllowedAt: timeToPG(next)}) - if errors.Is(err, pgx.ErrNoRows) { - return repository.ErrConflict - } - return mapMetadataError(err) -} - -func (s *Store) CreateJob(ctx context.Context, value model.Job, key string) (model.Job, error) { - return createJob(ctx, s.queries, value, key, s.propagator) -} -func (t *metadataTx) CreateJob(ctx context.Context, value model.Job, key string) (model.Job, error) { - return createJob(ctx, t.queries, value, key, t.propagator) -} -func createJob(ctx context.Context, q *db.Queries, value model.Job, key string, propagator propagation.TextMapPropagator) (model.Job, error) { - traceContext := value.TraceContext - if len(traceContext) == 0 { - traceContext = encodeTraceContext(ctx, propagator) - } - row, err := q.CreateJob(ctx, db.CreateJobParams{ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), Type: value.Type, Payload: value.Payload, IdempotencyKey: key, MaxAttempts: value.MaxAttempts, RunAfter: timeToPG(value.RunAfter), TraceContext: traceContext}) - if err != nil { - return model.Job{}, mapMetadataError(err) - } - return jobFromDB(row), nil -} - -func (s *Store) GetJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) { - return getJob(ctx, s.queries, workspaceID, id) -} -func (t *metadataTx) GetJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) { - return getJob(ctx, t.queries, workspaceID, id) -} -func getJob(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.Job, error) { - row, err := q.GetJob(ctx, db.GetJobParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - if err != nil { - return model.Job{}, mapMetadataError(err) - } - return jobFromDB(row), nil -} - -func (s *Store) CancelJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) { - return cancelJob(ctx, s.queries, workspaceID, id) -} -func (t *metadataTx) CancelJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) { - return cancelJob(ctx, t.queries, workspaceID, id) -} -func cancelJob(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.Job, error) { - row, err := q.CancelJob(ctx, db.CancelJobParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - if err != nil { - return model.Job{}, mapMetadataError(err) - } - return jobFromDB(row), nil -} - -func (s *Store) CancelActiveRepositorySyncJob(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Job, error) { - return cancelActiveRepositorySyncJob(ctx, s.queries, workspaceID, repositoryID) -} -func (t *metadataTx) CancelActiveRepositorySyncJob(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Job, error) { - return cancelActiveRepositorySyncJob(ctx, t.queries, workspaceID, repositoryID) -} -func cancelActiveRepositorySyncJob(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID) (model.Job, error) { - row, err := q.CancelActiveRepositorySyncJob(ctx, db.CancelActiveRepositorySyncJobParams{WorkspaceID: uuidToPG(workspaceID), RepositoryID: repositoryID.String()}) - if err != nil { - return model.Job{}, mapMetadataError(err) - } - return jobFromDB(row), nil -} - -func (s *Store) ListJobQueueDepths(ctx context.Context, types []string) ([]model.JobQueueDepth, error) { - return listJobQueueDepths(ctx, s.queries, types) -} -func (t *metadataTx) ListJobQueueDepths(ctx context.Context, types []string) ([]model.JobQueueDepth, error) { - return listJobQueueDepths(ctx, t.queries, types) -} -func listJobQueueDepths(ctx context.Context, q *db.Queries, types []string) ([]model.JobQueueDepth, error) { - rows, err := q.ListJobQueueDepths(ctx, types) - if err != nil { - return nil, err - } - depths := make([]model.JobQueueDepth, 0, len(rows)) - for _, row := range rows { - depths = append(depths, model.JobQueueDepth{Type: row.Type, Status: row.Status, Depth: row.Depth}) - } - return depths, nil -} - -func (s *Store) ClaimJob(ctx context.Context, types []string, owner string, until time.Time) (model.Job, error) { - return claimJob(ctx, s.queries, types, owner, until) -} -func (t *metadataTx) ClaimJob(ctx context.Context, types []string, owner string, until time.Time) (model.Job, error) { - return claimJob(ctx, t.queries, types, owner, until) -} -func claimJob(ctx context.Context, q *db.Queries, types []string, owner string, until time.Time) (model.Job, error) { - row, err := q.ClaimJob(ctx, db.ClaimJobParams{Types: types, LeaseOwner: &owner, LeaseUntil: timeToPG(until)}) - if err != nil { - return model.Job{}, mapMetadataError(err) - } - return jobFromDB(row), nil -} - -func (s *Store) RenewJobLease(ctx context.Context, job model.Job, until time.Time) error { - return renewJob(ctx, s.queries, job, until) -} -func (t *metadataTx) RenewJobLease(ctx context.Context, job model.Job, until time.Time) error { - return renewJob(ctx, t.queries, job, until) -} -func renewJob(ctx context.Context, q *db.Queries, job model.Job, until time.Time) error { - rows, err := q.RenewJobLease(ctx, db.RenewJobLeaseParams{LeaseUntil: timeToPG(until), ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken}) - return requireAffected(rows, err) -} - -func (s *Store) CompleteJob(ctx context.Context, job model.Job) error { - return completeJob(ctx, s.queries, job) -} -func (t *metadataTx) CompleteJob(ctx context.Context, job model.Job) error { - return completeJob(ctx, t.queries, job) -} -func completeJob(ctx context.Context, q *db.Queries, job model.Job) error { - rows, err := q.CompleteJob(ctx, db.CompleteJobParams{ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken}) - return requireAffected(rows, err) -} - -func (s *Store) RetryJob(ctx context.Context, job model.Job, runAfter time.Time, code, message string) error { - return retryJob(ctx, s.queries, job, runAfter, code, message) -} -func (t *metadataTx) RetryJob(ctx context.Context, job model.Job, runAfter time.Time, code, message string) error { - return retryJob(ctx, t.queries, job, runAfter, code, message) -} -func retryJob(ctx context.Context, q *db.Queries, job model.Job, runAfter time.Time, code, message string) error { - rows, err := q.RetryJob(ctx, db.RetryJobParams{RunAfter: timeToPG(runAfter), ErrorCode: code, ErrorMessage: message, ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken}) - return requireAffected(rows, err) -} - -func (s *Store) FailJob(ctx context.Context, job model.Job, code, message string) error { - return failJob(ctx, s.queries, job, code, message) -} -func (t *metadataTx) FailJob(ctx context.Context, job model.Job, code, message string) error { - return failJob(ctx, t.queries, job, code, message) -} -func failJob(ctx context.Context, q *db.Queries, job model.Job, code, message string) error { - rows, err := q.FailJob(ctx, db.FailJobParams{ErrorCode: code, ErrorMessage: message, ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken}) - return requireAffected(rows, err) -} - -func (s *Store) MarkRepositorySyncing(ctx context.Context, job model.Job, repositoryID uuid.UUID) error { - return s.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - return tx.MarkRepositorySyncing(ctx, job, repositoryID) - }) -} -func (t *metadataTx) MarkRepositorySyncing(ctx context.Context, job model.Job, repositoryID uuid.UUID) error { - if err := lockCurrentJob(ctx, t.queries, job); err != nil { - return err - } - return markRepoSyncing(ctx, t.queries, job.WorkspaceID, repositoryID) -} -func markRepoSyncing(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID) error { - rows, err := q.MarkRepositorySyncing(ctx, db.MarkRepositorySyncingParams{ID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID)}) - return requireAffected(rows, err) -} - -func (s *Store) MarkRepositoryFailed(ctx context.Context, workspaceID, repositoryID uuid.UUID, code, message string) error { - return markRepoFailed(ctx, s.queries, workspaceID, repositoryID, code, message) -} -func (t *metadataTx) MarkRepositoryFailed(ctx context.Context, workspaceID, repositoryID uuid.UUID, code, message string) error { - return markRepoFailed(ctx, t.queries, workspaceID, repositoryID, code, message) -} -func markRepoFailed(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID, code, message string) error { - rows, err := q.MarkRepositoryFailed(ctx, db.MarkRepositoryFailedParams{ErrorCode: code, ErrorMessage: message, ID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID)}) - return requireAffected(rows, err) -} - -func (s *Store) MarkSyncReady(ctx context.Context, job model.Job, repositoryID uuid.UUID, commitSHA string) error { - return s.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - return tx.MarkSyncReady(ctx, job, repositoryID, commitSHA) - }) -} -func (t *metadataTx) MarkSyncReady(ctx context.Context, job model.Job, repositoryID uuid.UUID, commitSHA string) error { - if err := lockCurrentJob(ctx, t.queries, job); err != nil { - return err - } - workspaceID := job.WorkspaceID - rows, err := t.queries.MarkRepositoryReady(ctx, db.MarkRepositoryReadyParams{CommitSha: commitSHA, ID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID)}) - if err := requireAffected(rows, err); err != nil { - return err - } - eventID, err := uuid.NewV7() - if err != nil { - return err - } - payload := []byte(`{"repositoryId":"` + repositoryID.String() + `","commitSha":"` + commitSHA + `"}`) - return t.queries.AppendOutboxEvent(ctx, db.AppendOutboxEventParams{ID: uuidToPG(eventID), WorkspaceID: uuidToPG(workspaceID), Aggregate: "repository", AggregateID: uuidToPG(repositoryID), EventType: "repository.synced", Payload: payload, TraceContext: encodeTraceContext(ctx, t.propagator)}) -} - -func (s *Store) MarkSyncFailed(ctx context.Context, job model.Job, repositoryID uuid.UUID, code, message string) error { - return s.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - return tx.MarkSyncFailed(ctx, job, repositoryID, code, message) - }) -} -func (t *metadataTx) MarkSyncFailed(ctx context.Context, job model.Job, repositoryID uuid.UUID, code, message string) error { - if err := lockCurrentJob(ctx, t.queries, job); err != nil { - return err - } - return markRepoFailed(ctx, t.queries, job.WorkspaceID, repositoryID, code, message) -} - -func lockCurrentJob(ctx context.Context, q *db.Queries, job model.Job) error { - _, err := q.LockCurrentJob(ctx, db.LockCurrentJobParams{ - ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken, - }) - if errors.Is(err, pgx.ErrNoRows) { - return repository.ErrLeaseLost - } - return err -} - -func (s *Store) AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error { - return appendAudit(ctx, s.queries, workspaceID, actorID, action, resourceType, resourceID, metadata) -} -func (t *metadataTx) AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error { - return appendAudit(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, metadata) -} -func appendAudit(ctx context.Context, q *db.Queries, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error { - return appendAuditResult(ctx, q, workspaceID, actorID, action, resourceType, resourceID, "success", metadata) -} -func (s *Store) AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error { - return appendAuditResult(ctx, s.queries, workspaceID, actorID, action, resourceType, resourceID, result, metadata) -} -func (t *metadataTx) AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error { - return appendAuditResult(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, result, metadata) -} -func appendAuditResult(ctx context.Context, q *db.Queries, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error { - id, err := uuid.NewV7() - if err != nil { - return err - } - return q.AppendAuditLog(ctx, db.AppendAuditLogParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID), ActorUserID: uuidToPG(actorID), Action: action, ResourceType: resourceType, ResourceID: nullableUUIDToPG(resourceID), Result: result, Metadata: metadata}) -} - -func (s *Store) ListAuditLogs(ctx context.Context, workspaceID uuid.UUID, cursor repository.TimeCursor, limit int32) ([]model.AuditLog, error) { - return listAuditLogs(ctx, s.queries, workspaceID, cursor, limit) -} -func (t *metadataTx) ListAuditLogs(ctx context.Context, workspaceID uuid.UUID, cursor repository.TimeCursor, limit int32) ([]model.AuditLog, error) { - return listAuditLogs(ctx, t.queries, workspaceID, cursor, limit) -} -func listAuditLogs(ctx context.Context, q *db.Queries, workspaceID uuid.UUID, cursor repository.TimeCursor, limit int32) ([]model.AuditLog, error) { - params := db.ListAuditLogsParams{WorkspaceID: uuidToPG(workspaceID), PageSize: limit} - if cursor.BeforeTime != nil { - params.BeforeTime = nullableTime(cursor.BeforeTime) - params.BeforeID = nullableUUID(cursor.BeforeID) - } - rows, err := q.ListAuditLogs(ctx, params) - if err != nil { - return nil, err - } - result := make([]model.AuditLog, 0, len(rows)) - for _, row := range rows { - result = append(result, model.AuditLog{ - ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), ActorUserID: uuidPointer(row.ActorUserID), - Action: row.Action, ResourceType: row.ResourceType, ResourceID: uuidPointer(row.ResourceID), - Result: row.Result, Metadata: row.Metadata, OccurredAt: row.OccurredAt.Time, - }) - } - return result, nil -} - -func (s *Store) GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (model.WorkspaceOverview, error) { - return getWorkspaceOverview(ctx, s.queries, workspaceID) -} - -func (t *metadataTx) GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (model.WorkspaceOverview, error) { - return getWorkspaceOverview(ctx, t.queries, workspaceID) -} - -func getWorkspaceOverview(ctx context.Context, queries *db.Queries, workspaceID uuid.UUID) (model.WorkspaceOverview, error) { - row, err := queries.GetWorkspaceOverview(ctx, uuidToPG(workspaceID)) - if err != nil { - return model.WorkspaceOverview{}, err - } - return model.WorkspaceOverview{RepositoryCount: row.RepositoryCount, ActiveChannelCount: row.ActiveChannelCount, FailedJobCount: row.FailedJobCount}, nil -} - -func connectionFromDB(row db.ScmConnection) model.SCMConnection { - secret := uuidPointer(row.SecretRef) - return model.SCMConnection{ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Type: row.Type, Name: row.Name, BaseURL: row.BaseUrl, AuthType: row.AuthType, SecretRef: secret, SecretConfigured: secret != nil, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} -} -func repositoryFromDB(row db.Repository) model.Repository { - return model.Repository{ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), ConnectionID: uuidPointer(row.ConnectionID), Name: row.Name, CloneURL: row.CloneUrl, NormalizedURL: row.NormalizedUrl, Ref: row.Ref, CurrentCommitSHA: row.CurrentCommitSha, State: row.State, LastErrorCode: row.LastErrorCode, LastErrorMessage: row.LastErrorMessage, SyncedAt: timePointer(row.SyncedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} -} -func jobFromDB(row db.Job) model.Job { - leaseOwner := "" - if row.LeaseOwner != nil { - leaseOwner = *row.LeaseOwner - } - return model.Job{ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Type: row.Type, Payload: row.Payload, Status: row.Status, Attempt: row.Attempt, MaxAttempts: row.MaxAttempts, LeaseOwner: leaseOwner, LeaseUntil: timePointer(row.LeaseUntil), FencingToken: row.FencingToken, RunAfter: row.RunAfter.Time, TraceContext: row.TraceContext, LastErrorCode: row.LastErrorCode, LastErrorMessage: row.LastErrorMessage, CreatedAt: row.CreatedAt.Time, StartedAt: timePointer(row.StartedAt), FinishedAt: timePointer(row.FinishedAt), UpdatedAt: row.UpdatedAt.Time} -} -func nullableUUID(value *uuid.UUID) pgtype.UUID { - if value == nil { - return pgtype.UUID{} - } - return uuidToPG(*value) -} -func uuidPointer(value pgtype.UUID) *uuid.UUID { - if !value.Valid { - return nil - } - result := uuidFromPG(value) - return &result -} -func timeToPG(value time.Time) pgtype.Timestamptz { - return pgtype.Timestamptz{Time: value.UTC(), Valid: true} -} -func timePointer(value pgtype.Timestamptz) *time.Time { - if !value.Valid { - return nil - } - result := value.Time - return &result -} -func requireAffected(rows int64, err error) error { - if err != nil { - return err - } - if rows == 0 { - return repository.ErrLeaseLost - } - return nil -} -func mapMetadataError(err error) error { - if errors.Is(err, pgx.ErrNoRows) { - return repository.ErrNotFound - } - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == "23505" { - return repository.ErrConflict - } - return err -} - -var _ repository.MetadataStore = (*Store)(nil) -var _ repository.MetadataStore = (*metadataTx)(nil) diff --git a/internal/repository/postgres/migrate.go b/internal/repository/postgres/migrate.go deleted file mode 100644 index 45766aa..0000000 --- a/internal/repository/postgres/migrate.go +++ /dev/null @@ -1,59 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "errors" - "fmt" - - "github.com/golang-migrate/migrate/v4" - pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5" - "github.com/golang-migrate/migrate/v4/source/iofs" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/stdlib" - "github.com/mooncode-ai/mooncode/internal/config" - moonmigrations "github.com/mooncode-ai/mooncode/migrations" -) - -func MigrateUp(ctx context.Context, cfg config.DatabaseConfig) error { - db, err := openMigrationDatabase(ctx, cfg) - if err != nil { - return err - } - defer func() { _ = db.Close() }() - - source, err := iofs.New(moonmigrations.PostgreSQL, "postgres") - if err != nil { - return fmt.Errorf("open embedded migrations: %w", err) - } - driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{}) - if err != nil { - return fmt.Errorf("create migration database driver: %w", err) - } - runner, err := migrate.NewWithInstance("iofs", source, "mooncode", driver) - if err != nil { - return fmt.Errorf("create migration runner: %w", err) - } - if err := runner.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { - return fmt.Errorf("apply PostgreSQL migrations: %w", err) - } - return nil -} - -func openMigrationDatabase(ctx context.Context, cfg config.DatabaseConfig) (*sql.DB, error) { - parsed, err := pgx.ParseConfig(cfg.URL) - if err != nil { - return nil, fmt.Errorf("parse database URL: %w", err) - } - db := stdlib.OpenDB(*parsed) - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - - connectCtx, cancel := context.WithTimeout(ctx, cfg.ConnectTimeout) - defer cancel() - if err := db.PingContext(connectCtx); err != nil { - _ = db.Close() - return nil, fmt.Errorf("connect to PostgreSQL for migration: %w", err) - } - return db, nil -} diff --git a/internal/repository/postgres/mutation.go b/internal/repository/postgres/mutation.go deleted file mode 100644 index 672f722..0000000 --- a/internal/repository/postgres/mutation.go +++ /dev/null @@ -1,120 +0,0 @@ -package postgres - -import ( - "context" - "errors" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/mooncode-ai/mooncode/internal/repository" -) - -type mutationRow struct { - idempotencyKey string - requestHash string - resourceID pgtype.UUID - status string - activeUntil pgtype.Timestamptz - nextAllowedAt pgtype.Timestamptz -} - -func (s *Store) BeginMutation(ctx context.Context, request repository.MutationRequest) (repository.MutationClaim, error) { - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return repository.MutationClaim{}, err - } - defer func() { _ = tx.Rollback(ctx) }() - - var existing mutationRow - err = tx.QueryRow(ctx, ` -SELECT idempotency_key, request_hash, resource_id, status, active_until, next_allowed_at -FROM mutation_controls -WHERE workspace_id = $1 AND operation = $2 AND resource_key = $3 -FOR UPDATE`, request.WorkspaceID, request.Operation, request.ResourceKey).Scan( - &existing.idempotencyKey, &existing.requestHash, &existing.resourceID, - &existing.status, &existing.activeUntil, &existing.nextAllowedAt, - ) - if err == nil { - sameRequest := existing.idempotencyKey == request.IdempotencyKey && existing.requestHash == request.RequestHash - if existing.idempotencyKey == request.IdempotencyKey && !sameRequest { - return repository.MutationClaim{}, repository.ErrConflict - } - if sameRequest && existing.status == "succeeded" { - if err := tx.Commit(ctx); err != nil { - return repository.MutationClaim{}, err - } - return repository.MutationClaim{Replay: true, ResourceID: uuidFromPG(existing.resourceID)}, nil - } - now := time.Now().UTC() - if (existing.status == "active" && existing.activeUntil.Time.After(now)) || existing.nextAllowedAt.Time.After(now) { - return repository.MutationClaim{}, repository.ErrConflict - } - command, updateErr := tx.Exec(ctx, ` -UPDATE mutation_controls -SET idempotency_key = $4, request_hash = $5, resource_id = $6, - status = 'active', active_until = $7, next_allowed_at = $8, updated_at = now() -WHERE workspace_id = $1 AND operation = $2 AND resource_key = $3`, - request.WorkspaceID, request.Operation, request.ResourceKey, - request.IdempotencyKey, request.RequestHash, nullableUUIDValue(request.ResourceID), - request.ActiveUntil, request.NextAllowedAt, - ) - if updateErr != nil { - return repository.MutationClaim{}, mapMetadataError(updateErr) - } - if command.RowsAffected() != 1 { - return repository.MutationClaim{}, repository.ErrConflict - } - } else if errors.Is(err, pgx.ErrNoRows) { - _, err = tx.Exec(ctx, ` -INSERT INTO mutation_controls ( - workspace_id, operation, resource_key, idempotency_key, request_hash, - resource_id, status, active_until, next_allowed_at -) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8)`, - request.WorkspaceID, request.Operation, request.ResourceKey, - request.IdempotencyKey, request.RequestHash, nullableUUIDValue(request.ResourceID), - request.ActiveUntil, request.NextAllowedAt, - ) - if err != nil { - return repository.MutationClaim{}, mapMetadataError(err) - } - } else { - return repository.MutationClaim{}, err - } - if err := tx.Commit(ctx); err != nil { - return repository.MutationClaim{}, err - } - return repository.MutationClaim{ResourceID: request.ResourceID}, nil -} - -func (s *Store) FinishMutation(ctx context.Context, request repository.MutationRequest, succeeded bool) error { - status := "failed" - if succeeded { - status = "succeeded" - } - command, err := s.pool.Exec(ctx, ` -UPDATE mutation_controls -SET status = $6, active_until = now(), updated_at = now() -WHERE workspace_id = $1 AND operation = $2 AND resource_key = $3 - AND idempotency_key = $4 AND request_hash = $5 AND status = 'active'`, - request.WorkspaceID, request.Operation, request.ResourceKey, - request.IdempotencyKey, request.RequestHash, status, - ) - if err != nil { - return err - } - if command.RowsAffected() != 1 { - return repository.ErrConflict - } - return nil -} - -func nullableUUIDValue(value uuid.UUID) any { - if value == uuid.Nil { - return nil - } - return value -} - -var _ repository.MutationStore = (*Store)(nil) diff --git a/internal/repository/postgres/outbox.go b/internal/repository/postgres/outbox.go deleted file mode 100644 index f770df4..0000000 --- a/internal/repository/postgres/outbox.go +++ /dev/null @@ -1,51 +0,0 @@ -package postgres - -import ( - "context" - "time" - - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc" -) - -func (s *Store) ClaimOutboxEvent(ctx context.Context, owner string, leaseUntil time.Time) (model.OutboxEvent, error) { - row, err := s.queries.ClaimOutboxEvent(ctx, db.ClaimOutboxEventParams{LeaseOwner: &owner, LeaseUntil: timeToPG(leaseUntil)}) - if err != nil { - return model.OutboxEvent{}, mapMetadataError(err) - } - return outboxEventFromDB(row), nil -} - -func (s *Store) CompleteOutboxEvent(ctx context.Context, event model.OutboxEvent) error { - rows, err := s.queries.CompleteOutboxEvent(ctx, db.CompleteOutboxEventParams{ - ID: uuidToPG(event.ID), LeaseOwner: &event.LeaseOwner, Attempt: event.Attempt, - }) - return requireAffected(rows, err) -} - -func (s *Store) RetryOutboxEvent(ctx context.Context, event model.OutboxEvent, nextAttempt time.Time, code, message string) error { - rows, err := s.queries.RetryOutboxEvent(ctx, db.RetryOutboxEventParams{ - NextAttemptAt: timeToPG(nextAttempt), ErrorCode: code, ErrorMessage: message, - ID: uuidToPG(event.ID), LeaseOwner: &event.LeaseOwner, Attempt: event.Attempt, - }) - return requireAffected(rows, err) -} - -func (s *Store) CountOutboxBacklog(ctx context.Context) (int64, error) { - return s.queries.CountOutboxBacklog(ctx) -} - -func outboxEventFromDB(row db.OutboxEvent) model.OutboxEvent { - owner := "" - if row.LeaseOwner != nil { - owner = *row.LeaseOwner - } - return model.OutboxEvent{ - ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Aggregate: row.Aggregate, - AggregateID: uuidFromPG(row.AggregateID), Type: row.EventType, Payload: row.Payload, TraceContext: row.TraceContext, - LeaseOwner: owner, LeaseUntil: timePointer(row.LeaseUntil), Attempt: row.Attempt, CreatedAt: row.CreatedAt.Time, - } -} - -var _ repository.OutboxStore = (*Store)(nil) diff --git a/internal/repository/postgres/secret.go b/internal/repository/postgres/secret.go deleted file mode 100644 index 6368546..0000000 --- a/internal/repository/postgres/secret.go +++ /dev/null @@ -1,42 +0,0 @@ -package postgres - -import ( - "context" - "errors" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc" - "github.com/mooncode-ai/mooncode/internal/secretstore" -) - -func (s *Store) CreateSecretRecord(ctx context.Context, record secretstore.Record) error { - _, err := s.queries.CreateSecret(ctx, db.CreateSecretParams{ - ID: uuidToPG(record.ID), WorkspaceID: uuidToPG(record.Scope.WorkspaceID), - ResourceType: record.Scope.ResourceType, ResourceID: uuidToPG(record.Scope.ResourceID), - Ciphertext: record.Ciphertext, Nonce: record.Nonce, WrappedKey: record.WrappedKey, - WrappedKeyNonce: record.WrappedKeyNonce, KeyVersion: record.KeyVersion, - }) - return err -} - -func (s *Store) GetSecretRecord(ctx context.Context, id, workspaceID uuid.UUID) (secretstore.Record, error) { - row, err := s.queries.GetSecret(ctx, db.GetSecretParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) - if errors.Is(err, pgx.ErrNoRows) { - return secretstore.Record{}, secretstore.ErrNotFound - } - if err != nil { - return secretstore.Record{}, err - } - return secretstore.Record{ - ID: uuidFromPG(row.ID), Scope: secretstore.Scope{WorkspaceID: uuidFromPG(row.WorkspaceID), ResourceType: row.ResourceType, ResourceID: uuidFromPG(row.ResourceID)}, - Ciphertext: row.Ciphertext, Nonce: row.Nonce, WrappedKey: row.WrappedKey, - WrappedKeyNonce: row.WrappedKeyNonce, KeyVersion: row.KeyVersion, - }, nil -} - -func (s *Store) DeleteSecretRecord(ctx context.Context, id, workspaceID uuid.UUID) error { - return s.queries.DeleteSecret(ctx, db.DeleteSecretParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)}) -} - -var _ secretstore.RecordStore = (*Store)(nil) diff --git a/internal/repository/postgres/sqlc/channels.sql.go b/internal/repository/postgres/sqlc/channels.sql.go deleted file mode 100644 index 82fa1d7..0000000 --- a/internal/repository/postgres/sqlc/channels.sql.go +++ /dev/null @@ -1,705 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: channels.sql - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const acquireChannelLease = `-- name: AcquireChannelLease :one -INSERT INTO channel_leases (channel_instance_id, owner, lease_until, fencing_token) -VALUES ($1, $2, $3, 1) -ON CONFLICT (channel_instance_id) DO UPDATE -SET owner = EXCLUDED.owner, lease_until = EXCLUDED.lease_until, - fencing_token = CASE WHEN channel_leases.owner = EXCLUDED.owner - THEN channel_leases.fencing_token ELSE channel_leases.fencing_token + 1 END, - updated_at = now() -WHERE channel_leases.owner = EXCLUDED.owner OR channel_leases.lease_until < now() -RETURNING channel_instance_id, owner, lease_until, fencing_token, updated_at -` - -type AcquireChannelLeaseParams struct { - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - Owner string `json:"owner"` - LeaseUntil pgtype.Timestamptz `json:"lease_until"` -} - -func (q *Queries) AcquireChannelLease(ctx context.Context, arg AcquireChannelLeaseParams) (ChannelLease, error) { - row := q.db.QueryRow(ctx, acquireChannelLease, arg.ChannelInstanceID, arg.Owner, arg.LeaseUntil) - var i ChannelLease - err := row.Scan( - &i.ChannelInstanceID, - &i.Owner, - &i.LeaseUntil, - &i.FencingToken, - &i.UpdatedAt, - ) - return i, err -} - -const createChannelInstance = `-- name: CreateChannelInstance :one -INSERT INTO channel_instances (id, workspace_id, type, name, enabled, config, secret_ref) -VALUES ($1, $2, $3, $4, false, - $5, $6) -RETURNING id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at -` - -type CreateChannelInstanceParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - Type string `json:"type"` - Name string `json:"name"` - Config []byte `json:"config"` - SecretRef pgtype.UUID `json:"secret_ref"` -} - -func (q *Queries) CreateChannelInstance(ctx context.Context, arg CreateChannelInstanceParams) (ChannelInstance, error) { - row := q.db.QueryRow(ctx, createChannelInstance, - arg.ID, - arg.WorkspaceID, - arg.Type, - arg.Name, - arg.Config, - arg.SecretRef, - ) - var i ChannelInstance - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.Enabled, - &i.Config, - &i.SecretRef, - &i.ConfigVersion, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const getChannelInstance = `-- name: GetChannelInstance :one -SELECT id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at FROM channel_instances -WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL -` - -type GetChannelInstanceParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) GetChannelInstance(ctx context.Context, arg GetChannelInstanceParams) (ChannelInstance, error) { - row := q.db.QueryRow(ctx, getChannelInstance, arg.ID, arg.WorkspaceID) - var i ChannelInstance - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.Enabled, - &i.Config, - &i.SecretRef, - &i.ConfigVersion, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const getChannelRuntimeStatus = `-- name: GetChannelRuntimeStatus :one -SELECT channel_instance_id, state, backend_instance_id, fencing_token, last_connected_at, last_error_code, last_error_message, updated_at FROM channel_runtime_status WHERE channel_instance_id = $1 -` - -func (q *Queries) GetChannelRuntimeStatus(ctx context.Context, channelInstanceID pgtype.UUID) (ChannelRuntimeStatus, error) { - row := q.db.QueryRow(ctx, getChannelRuntimeStatus, channelInstanceID) - var i ChannelRuntimeStatus - err := row.Scan( - &i.ChannelInstanceID, - &i.State, - &i.BackendInstanceID, - &i.FencingToken, - &i.LastConnectedAt, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.UpdatedAt, - ) - return i, err -} - -const insertIMMessage = `-- name: InsertIMMessage :one -INSERT INTO im_messages ( - id, workspace_id, channel_instance_id, conversation_id, sender_id, - external_message_id, content, occurred_at -) VALUES ( - $1, $2, $3, - $4, $5, $6, - $7, $8 -) -ON CONFLICT (channel_instance_id, external_message_id) DO NOTHING -RETURNING id, workspace_id, channel_instance_id, conversation_id, sender_id, external_message_id, content, occurred_at, received_at, created_at -` - -type InsertIMMessageParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - ConversationID pgtype.UUID `json:"conversation_id"` - SenderID pgtype.UUID `json:"sender_id"` - ExternalMessageID string `json:"external_message_id"` - Content []byte `json:"content"` - OccurredAt pgtype.Timestamptz `json:"occurred_at"` -} - -func (q *Queries) InsertIMMessage(ctx context.Context, arg InsertIMMessageParams) (ImMessage, error) { - row := q.db.QueryRow(ctx, insertIMMessage, - arg.ID, - arg.WorkspaceID, - arg.ChannelInstanceID, - arg.ConversationID, - arg.SenderID, - arg.ExternalMessageID, - arg.Content, - arg.OccurredAt, - ) - var i ImMessage - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.ChannelInstanceID, - &i.ConversationID, - &i.SenderID, - &i.ExternalMessageID, - &i.Content, - &i.OccurredAt, - &i.ReceivedAt, - &i.CreatedAt, - ) - return i, err -} - -const insertInboxEvent = `-- name: InsertInboxEvent :one -INSERT INTO inbox_events (channel_instance_id, external_event_id, payload_hash) -VALUES ($1, $2, $3) -ON CONFLICT DO NOTHING -RETURNING external_event_id -` - -type InsertInboxEventParams struct { - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - ExternalEventID string `json:"external_event_id"` - PayloadHash string `json:"payload_hash"` -} - -func (q *Queries) InsertInboxEvent(ctx context.Context, arg InsertInboxEventParams) (string, error) { - row := q.db.QueryRow(ctx, insertInboxEvent, arg.ChannelInstanceID, arg.ExternalEventID, arg.PayloadHash) - var external_event_id string - err := row.Scan(&external_event_id) - return external_event_id, err -} - -const listChannelInstances = `-- name: ListChannelInstances :many -SELECT id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at FROM channel_instances -WHERE workspace_id = $1 AND deleted_at IS NULL -ORDER BY created_at DESC, id DESC -` - -func (q *Queries) ListChannelInstances(ctx context.Context, workspaceID pgtype.UUID) ([]ChannelInstance, error) { - rows, err := q.db.Query(ctx, listChannelInstances, workspaceID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ChannelInstance{} - for rows.Next() { - var i ChannelInstance - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.Enabled, - &i.Config, - &i.SecretRef, - &i.ConfigVersion, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listEnabledChannelInstances = `-- name: ListEnabledChannelInstances :many -SELECT id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at FROM channel_instances -WHERE enabled = true AND deleted_at IS NULL -ORDER BY id -` - -func (q *Queries) ListEnabledChannelInstances(ctx context.Context) ([]ChannelInstance, error) { - rows, err := q.db.Query(ctx, listEnabledChannelInstances) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ChannelInstance{} - for rows.Next() { - var i ChannelInstance - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.Enabled, - &i.Config, - &i.SecretRef, - &i.ConfigVersion, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listIMConversations = `-- name: ListIMConversations :many -SELECT c.id, c.workspace_id, c.channel_instance_id, c.external_id, c.type, c.title, c.created_at, c.updated_at, ci.name AS channel_name, ci.type AS channel_type -FROM im_conversations c -JOIN channel_instances ci ON ci.id = c.channel_instance_id AND ci.workspace_id = c.workspace_id -WHERE c.workspace_id = $1 -ORDER BY c.updated_at DESC, c.id DESC -LIMIT $2 -` - -type ListIMConversationsParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - PageSize int32 `json:"page_size"` -} - -type ListIMConversationsRow struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - ExternalID string `json:"external_id"` - Type string `json:"type"` - Title string `json:"title"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - ChannelName string `json:"channel_name"` - ChannelType string `json:"channel_type"` -} - -func (q *Queries) ListIMConversations(ctx context.Context, arg ListIMConversationsParams) ([]ListIMConversationsRow, error) { - rows, err := q.db.Query(ctx, listIMConversations, arg.WorkspaceID, arg.PageSize) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListIMConversationsRow{} - for rows.Next() { - var i ListIMConversationsRow - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.ChannelInstanceID, - &i.ExternalID, - &i.Type, - &i.Title, - &i.CreatedAt, - &i.UpdatedAt, - &i.ChannelName, - &i.ChannelType, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listIMMessages = `-- name: ListIMMessages :many -SELECT m.id, m.workspace_id, m.channel_instance_id, m.conversation_id, m.sender_id, m.external_message_id, m.content, m.occurred_at, m.received_at, m.created_at, c.external_id AS conversation_external_id, c.type AS conversation_type, - s.canonical_id AS sender_canonical_id, s.display_name AS sender_display_name, - ci.name AS channel_name, ci.type AS channel_type -FROM im_messages m -JOIN im_conversations c ON c.id = m.conversation_id AND c.workspace_id = m.workspace_id -JOIN im_senders s ON s.id = m.sender_id AND s.workspace_id = m.workspace_id -JOIN channel_instances ci ON ci.id = m.channel_instance_id AND ci.workspace_id = m.workspace_id -WHERE m.workspace_id = $1 - AND ($2::uuid IS NULL OR m.channel_instance_id = $2) - AND ($3::uuid IS NULL OR m.conversation_id = $3) - AND ($4::timestamptz IS NULL OR (m.occurred_at, m.id) < ($4, $5::uuid)) -ORDER BY m.occurred_at DESC, m.id DESC -LIMIT $6 -` - -type ListIMMessagesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - ConversationID pgtype.UUID `json:"conversation_id"` - BeforeTime pgtype.Timestamptz `json:"before_time"` - BeforeID pgtype.UUID `json:"before_id"` - PageSize int32 `json:"page_size"` -} - -type ListIMMessagesRow struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - ConversationID pgtype.UUID `json:"conversation_id"` - SenderID pgtype.UUID `json:"sender_id"` - ExternalMessageID string `json:"external_message_id"` - Content []byte `json:"content"` - OccurredAt pgtype.Timestamptz `json:"occurred_at"` - ReceivedAt pgtype.Timestamptz `json:"received_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - ConversationExternalID string `json:"conversation_external_id"` - ConversationType string `json:"conversation_type"` - SenderCanonicalID string `json:"sender_canonical_id"` - SenderDisplayName string `json:"sender_display_name"` - ChannelName string `json:"channel_name"` - ChannelType string `json:"channel_type"` -} - -func (q *Queries) ListIMMessages(ctx context.Context, arg ListIMMessagesParams) ([]ListIMMessagesRow, error) { - rows, err := q.db.Query(ctx, listIMMessages, - arg.WorkspaceID, - arg.ChannelInstanceID, - arg.ConversationID, - arg.BeforeTime, - arg.BeforeID, - arg.PageSize, - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListIMMessagesRow{} - for rows.Next() { - var i ListIMMessagesRow - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.ChannelInstanceID, - &i.ConversationID, - &i.SenderID, - &i.ExternalMessageID, - &i.Content, - &i.OccurredAt, - &i.ReceivedAt, - &i.CreatedAt, - &i.ConversationExternalID, - &i.ConversationType, - &i.SenderCanonicalID, - &i.SenderDisplayName, - &i.ChannelName, - &i.ChannelType, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const releaseChannelLease = `-- name: ReleaseChannelLease :exec -DELETE FROM channel_leases -WHERE channel_instance_id = $1 AND owner = $2 - AND fencing_token = $3 -` - -type ReleaseChannelLeaseParams struct { - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - Owner string `json:"owner"` - FencingToken int64 `json:"fencing_token"` -} - -func (q *Queries) ReleaseChannelLease(ctx context.Context, arg ReleaseChannelLeaseParams) error { - _, err := q.db.Exec(ctx, releaseChannelLease, arg.ChannelInstanceID, arg.Owner, arg.FencingToken) - return err -} - -const renewChannelLease = `-- name: RenewChannelLease :execrows -UPDATE channel_leases SET lease_until = $1, updated_at = now() -WHERE channel_instance_id = $2 AND owner = $3 - AND fencing_token = $4 -` - -type RenewChannelLeaseParams struct { - LeaseUntil pgtype.Timestamptz `json:"lease_until"` - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - Owner string `json:"owner"` - FencingToken int64 `json:"fencing_token"` -} - -func (q *Queries) RenewChannelLease(ctx context.Context, arg RenewChannelLeaseParams) (int64, error) { - result, err := q.db.Exec(ctx, renewChannelLease, - arg.LeaseUntil, - arg.ChannelInstanceID, - arg.Owner, - arg.FencingToken, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const setChannelEnabled = `-- name: SetChannelEnabled :one -UPDATE channel_instances SET enabled = $1, config_version = config_version + 1, - updated_at = now() -WHERE id = $2 AND workspace_id = $3 AND deleted_at IS NULL -RETURNING id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at -` - -type SetChannelEnabledParams struct { - Enabled bool `json:"enabled"` - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) SetChannelEnabled(ctx context.Context, arg SetChannelEnabledParams) (ChannelInstance, error) { - row := q.db.QueryRow(ctx, setChannelEnabled, arg.Enabled, arg.ID, arg.WorkspaceID) - var i ChannelInstance - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.Enabled, - &i.Config, - &i.SecretRef, - &i.ConfigVersion, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const setChannelRuntimeStatus = `-- name: SetChannelRuntimeStatus :execrows -INSERT INTO channel_runtime_status ( - channel_instance_id, state, backend_instance_id, fencing_token, - last_connected_at, last_error_code, last_error_message -) SELECT - lease.channel_instance_id, $1, $2, - $3, $4, $5, - $6 -FROM channel_leases AS lease -WHERE lease.channel_instance_id = $7 - AND lease.owner = $2 - AND lease.fencing_token = $3 - AND lease.lease_until > now() -ON CONFLICT (channel_instance_id) DO UPDATE SET - state = EXCLUDED.state, backend_instance_id = EXCLUDED.backend_instance_id, - fencing_token = EXCLUDED.fencing_token, - last_connected_at = COALESCE(EXCLUDED.last_connected_at, channel_runtime_status.last_connected_at), - last_error_code = EXCLUDED.last_error_code, - last_error_message = EXCLUDED.last_error_message, updated_at = now() -WHERE channel_runtime_status.fencing_token <= EXCLUDED.fencing_token -` - -type SetChannelRuntimeStatusParams struct { - State string `json:"state"` - BackendInstanceID string `json:"backend_instance_id"` - FencingToken int64 `json:"fencing_token"` - LastConnectedAt pgtype.Timestamptz `json:"last_connected_at"` - LastErrorCode string `json:"last_error_code"` - LastErrorMessage string `json:"last_error_message"` - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` -} - -func (q *Queries) SetChannelRuntimeStatus(ctx context.Context, arg SetChannelRuntimeStatusParams) (int64, error) { - result, err := q.db.Exec(ctx, setChannelRuntimeStatus, - arg.State, - arg.BackendInstanceID, - arg.FencingToken, - arg.LastConnectedAt, - arg.LastErrorCode, - arg.LastErrorMessage, - arg.ChannelInstanceID, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const softDeleteChannel = `-- name: SoftDeleteChannel :one -UPDATE channel_instances -SET enabled = false, secret_ref = NULL, deleted_at = now(), config_version = config_version + 1, updated_at = now() -WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL -RETURNING id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at -` - -type SoftDeleteChannelParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) SoftDeleteChannel(ctx context.Context, arg SoftDeleteChannelParams) (ChannelInstance, error) { - row := q.db.QueryRow(ctx, softDeleteChannel, arg.ID, arg.WorkspaceID) - var i ChannelInstance - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.Enabled, - &i.Config, - &i.SecretRef, - &i.ConfigVersion, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const updateChannelInstance = `-- name: UpdateChannelInstance :one -UPDATE channel_instances SET name = $1, config = $2, - secret_ref = $3, config_version = config_version + 1, updated_at = now() -WHERE id = $4 AND workspace_id = $5 - AND config_version = $6 AND deleted_at IS NULL -RETURNING id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at -` - -type UpdateChannelInstanceParams struct { - Name string `json:"name"` - Config []byte `json:"config"` - SecretRef pgtype.UUID `json:"secret_ref"` - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ExpectedVersion int64 `json:"expected_version"` -} - -func (q *Queries) UpdateChannelInstance(ctx context.Context, arg UpdateChannelInstanceParams) (ChannelInstance, error) { - row := q.db.QueryRow(ctx, updateChannelInstance, - arg.Name, - arg.Config, - arg.SecretRef, - arg.ID, - arg.WorkspaceID, - arg.ExpectedVersion, - ) - var i ChannelInstance - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.Enabled, - &i.Config, - &i.SecretRef, - &i.ConfigVersion, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const upsertIMConversation = `-- name: UpsertIMConversation :one -INSERT INTO im_conversations (id, workspace_id, channel_instance_id, external_id, type, title) -VALUES ($1, $2, $3, - $4, $5, $6) -ON CONFLICT (channel_instance_id, external_id) DO UPDATE -SET type = EXCLUDED.type, title = CASE WHEN EXCLUDED.title = '' THEN im_conversations.title ELSE EXCLUDED.title END, - updated_at = now() -RETURNING id, workspace_id, channel_instance_id, external_id, type, title, created_at, updated_at -` - -type UpsertIMConversationParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - ExternalID string `json:"external_id"` - Type string `json:"type"` - Title string `json:"title"` -} - -func (q *Queries) UpsertIMConversation(ctx context.Context, arg UpsertIMConversationParams) (ImConversation, error) { - row := q.db.QueryRow(ctx, upsertIMConversation, - arg.ID, - arg.WorkspaceID, - arg.ChannelInstanceID, - arg.ExternalID, - arg.Type, - arg.Title, - ) - var i ImConversation - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.ChannelInstanceID, - &i.ExternalID, - &i.Type, - &i.Title, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const upsertIMSender = `-- name: UpsertIMSender :one -INSERT INTO im_senders (id, workspace_id, channel_type, canonical_id, display_name) -VALUES ($1, $2, $3, - $4, $5) -ON CONFLICT (workspace_id, canonical_id) DO UPDATE -SET display_name = CASE WHEN EXCLUDED.display_name = '' THEN im_senders.display_name ELSE EXCLUDED.display_name END, - updated_at = now() -RETURNING id, workspace_id, channel_type, canonical_id, display_name, created_at, updated_at -` - -type UpsertIMSenderParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ChannelType string `json:"channel_type"` - CanonicalID string `json:"canonical_id"` - DisplayName string `json:"display_name"` -} - -func (q *Queries) UpsertIMSender(ctx context.Context, arg UpsertIMSenderParams) (ImSender, error) { - row := q.db.QueryRow(ctx, upsertIMSender, - arg.ID, - arg.WorkspaceID, - arg.ChannelType, - arg.CanonicalID, - arg.DisplayName, - ) - var i ImSender - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.ChannelType, - &i.CanonicalID, - &i.DisplayName, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} diff --git a/internal/repository/postgres/sqlc/identity.sql.go b/internal/repository/postgres/sqlc/identity.sql.go deleted file mode 100644 index 210398c..0000000 --- a/internal/repository/postgres/sqlc/identity.sql.go +++ /dev/null @@ -1,648 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: identity.sql - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const acceptWorkspaceInvitation = `-- name: AcceptWorkspaceInvitation :execrows -UPDATE workspace_invitations SET - accepted_at = now(), - accepted_by = $1, - updated_at = now() -WHERE id = $2 AND accepted_at IS NULL AND revoked_at IS NULL -` - -type AcceptWorkspaceInvitationParams struct { - AcceptedBy pgtype.UUID `json:"accepted_by"` - ID pgtype.UUID `json:"id"` -} - -func (q *Queries) AcceptWorkspaceInvitation(ctx context.Context, arg AcceptWorkspaceInvitationParams) (int64, error) { - result, err := q.db.Exec(ctx, acceptWorkspaceInvitation, arg.AcceptedBy, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const activateUser = `-- name: ActivateUser :one -UPDATE users SET - status = 'active', - terms_version = $1, - privacy_version = $2, - activated_at = COALESCE(activated_at, now()), - agreements_accepted_at = now(), - updated_at = now() -WHERE id = $3 AND status IN ('pending', 'active') -RETURNING id, issuer, external_subject, username, email, display_name, status, activated_at, suspended_at, last_seen_at, terms_version, privacy_version, agreements_accepted_at, deleted_at, created_at, updated_at -` - -type ActivateUserParams struct { - TermsVersion string `json:"terms_version"` - PrivacyVersion string `json:"privacy_version"` - ID pgtype.UUID `json:"id"` -} - -func (q *Queries) ActivateUser(ctx context.Context, arg ActivateUserParams) (User, error) { - row := q.db.QueryRow(ctx, activateUser, arg.TermsVersion, arg.PrivacyVersion, arg.ID) - var i User - err := row.Scan( - &i.ID, - &i.Issuer, - &i.ExternalSubject, - &i.Username, - &i.Email, - &i.DisplayName, - &i.Status, - &i.ActivatedAt, - &i.SuspendedAt, - &i.LastSeenAt, - &i.TermsVersion, - &i.PrivacyVersion, - &i.AgreementsAcceptedAt, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const addWorkspaceMember = `-- name: AddWorkspaceMember :one -INSERT INTO workspace_members (workspace_id, user_id, role) -VALUES ($1, $2, $3) -ON CONFLICT (workspace_id, user_id) DO NOTHING -RETURNING workspace_id, user_id, role, created_at, updated_at -` - -type AddWorkspaceMemberParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - UserID pgtype.UUID `json:"user_id"` - Role string `json:"role"` -} - -func (q *Queries) AddWorkspaceMember(ctx context.Context, arg AddWorkspaceMemberParams) (WorkspaceMember, error) { - row := q.db.QueryRow(ctx, addWorkspaceMember, arg.WorkspaceID, arg.UserID, arg.Role) - var i WorkspaceMember - err := row.Scan( - &i.WorkspaceID, - &i.UserID, - &i.Role, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const appendAccountAudit = `-- name: AppendAccountAudit :exec -INSERT INTO account_audit_logs ( - id, user_id, actor_user_id, action, result, provider, request_id, metadata, occurred_at -) VALUES ( - $1, $2, $3, $4, - $5, $6, $7, $8, $9 -) -` - -type AppendAccountAuditParams struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` - ActorUserID pgtype.UUID `json:"actor_user_id"` - Action string `json:"action"` - Result string `json:"result"` - Provider string `json:"provider"` - RequestID string `json:"request_id"` - Metadata []byte `json:"metadata"` - OccurredAt pgtype.Timestamptz `json:"occurred_at"` -} - -func (q *Queries) AppendAccountAudit(ctx context.Context, arg AppendAccountAuditParams) error { - _, err := q.db.Exec(ctx, appendAccountAudit, - arg.ID, - arg.UserID, - arg.ActorUserID, - arg.Action, - arg.Result, - arg.Provider, - arg.RequestID, - arg.Metadata, - arg.OccurredAt, - ) - return err -} - -const createWorkspace = `-- name: CreateWorkspace :one -INSERT INTO workspaces (id, name, slug, kind, created_by) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, name, slug, kind, created_by, created_at, updated_at, deleted_at -` - -type CreateWorkspaceParams struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Kind string `json:"kind"` - CreatedBy pgtype.UUID `json:"created_by"` -} - -func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) { - row := q.db.QueryRow(ctx, createWorkspace, - arg.ID, - arg.Name, - arg.Slug, - arg.Kind, - arg.CreatedBy, - ) - var i Workspace - err := row.Scan( - &i.ID, - &i.Name, - &i.Slug, - &i.Kind, - &i.CreatedBy, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const createWorkspaceInvitation = `-- name: CreateWorkspaceInvitation :one -INSERT INTO workspace_invitations ( - id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at -) VALUES ( - $1, $2, $3, $4, - $5, $6, $7 -) -RETURNING id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at -` - -type CreateWorkspaceInvitationParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - EmailNormalized string `json:"email_normalized"` - Role string `json:"role"` - TokenHash []byte `json:"token_hash"` - InvitedBy pgtype.UUID `json:"invited_by"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` -} - -func (q *Queries) CreateWorkspaceInvitation(ctx context.Context, arg CreateWorkspaceInvitationParams) (WorkspaceInvitation, error) { - row := q.db.QueryRow(ctx, createWorkspaceInvitation, - arg.ID, - arg.WorkspaceID, - arg.EmailNormalized, - arg.Role, - arg.TokenHash, - arg.InvitedBy, - arg.ExpiresAt, - ) - var i WorkspaceInvitation - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.EmailNormalized, - &i.Role, - &i.TokenHash, - &i.InvitedBy, - &i.ExpiresAt, - &i.AcceptedAt, - &i.AcceptedBy, - &i.RevokedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const deleteWorkspaceMember = `-- name: DeleteWorkspaceMember :execrows -DELETE FROM workspace_members -WHERE workspace_id = $1 AND user_id = $2 -` - -type DeleteWorkspaceMemberParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - UserID pgtype.UUID `json:"user_id"` -} - -func (q *Queries) DeleteWorkspaceMember(ctx context.Context, arg DeleteWorkspaceMemberParams) (int64, error) { - result, err := q.db.Exec(ctx, deleteWorkspaceMember, arg.WorkspaceID, arg.UserID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getPersonalWorkspaceForUser = `-- name: GetPersonalWorkspaceForUser :one -SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, w.deleted_at -FROM workspaces w -JOIN workspace_members wm ON wm.workspace_id = w.id -WHERE wm.user_id = $1 - AND w.kind = 'personal' - AND w.deleted_at IS NULL -ORDER BY w.created_at -LIMIT 1 -` - -func (q *Queries) GetPersonalWorkspaceForUser(ctx context.Context, userID pgtype.UUID) (Workspace, error) { - row := q.db.QueryRow(ctx, getPersonalWorkspaceForUser, userID) - var i Workspace - err := row.Scan( - &i.ID, - &i.Name, - &i.Slug, - &i.Kind, - &i.CreatedBy, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const getUserByExternalIdentity = `-- name: GetUserByExternalIdentity :one -SELECT id, issuer, external_subject, username, email, display_name, status, activated_at, suspended_at, last_seen_at, terms_version, privacy_version, agreements_accepted_at, deleted_at, created_at, updated_at FROM users -WHERE issuer = $1 AND external_subject = $2 -` - -type GetUserByExternalIdentityParams struct { - Issuer string `json:"issuer"` - ExternalSubject string `json:"external_subject"` -} - -func (q *Queries) GetUserByExternalIdentity(ctx context.Context, arg GetUserByExternalIdentityParams) (User, error) { - row := q.db.QueryRow(ctx, getUserByExternalIdentity, arg.Issuer, arg.ExternalSubject) - var i User - err := row.Scan( - &i.ID, - &i.Issuer, - &i.ExternalSubject, - &i.Username, - &i.Email, - &i.DisplayName, - &i.Status, - &i.ActivatedAt, - &i.SuspendedAt, - &i.LastSeenAt, - &i.TermsVersion, - &i.PrivacyVersion, - &i.AgreementsAcceptedAt, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const getUserForUpdate = `-- name: GetUserForUpdate :one -SELECT id, issuer, external_subject, username, email, display_name, status, activated_at, suspended_at, last_seen_at, terms_version, privacy_version, agreements_accepted_at, deleted_at, created_at, updated_at FROM users -WHERE id = $1 -FOR UPDATE -` - -func (q *Queries) GetUserForUpdate(ctx context.Context, id pgtype.UUID) (User, error) { - row := q.db.QueryRow(ctx, getUserForUpdate, id) - var i User - err := row.Scan( - &i.ID, - &i.Issuer, - &i.ExternalSubject, - &i.Username, - &i.Email, - &i.DisplayName, - &i.Status, - &i.ActivatedAt, - &i.SuspendedAt, - &i.LastSeenAt, - &i.TermsVersion, - &i.PrivacyVersion, - &i.AgreementsAcceptedAt, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const getWorkspaceInvitationForUpdate = `-- name: GetWorkspaceInvitationForUpdate :one -SELECT id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at FROM workspace_invitations -WHERE token_hash = $1 -FOR UPDATE -` - -func (q *Queries) GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (WorkspaceInvitation, error) { - row := q.db.QueryRow(ctx, getWorkspaceInvitationForUpdate, tokenHash) - var i WorkspaceInvitation - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.EmailNormalized, - &i.Role, - &i.TokenHash, - &i.InvitedBy, - &i.ExpiresAt, - &i.AcceptedAt, - &i.AcceptedBy, - &i.RevokedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const getWorkspaceMembership = `-- name: GetWorkspaceMembership :one -SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, wm.role -FROM workspaces w -JOIN workspace_members wm ON wm.workspace_id = w.id -WHERE w.id = $1 - AND wm.user_id = $2 - AND w.deleted_at IS NULL -` - -type GetWorkspaceMembershipParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - UserID pgtype.UUID `json:"user_id"` -} - -type GetWorkspaceMembershipRow struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Kind string `json:"kind"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Role string `json:"role"` -} - -func (q *Queries) GetWorkspaceMembership(ctx context.Context, arg GetWorkspaceMembershipParams) (GetWorkspaceMembershipRow, error) { - row := q.db.QueryRow(ctx, getWorkspaceMembership, arg.WorkspaceID, arg.UserID) - var i GetWorkspaceMembershipRow - err := row.Scan( - &i.ID, - &i.Name, - &i.Slug, - &i.Kind, - &i.CreatedBy, - &i.CreatedAt, - &i.UpdatedAt, - &i.Role, - ) - return i, err -} - -const listWorkspaceInvitations = `-- name: ListWorkspaceInvitations :many -SELECT id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at FROM workspace_invitations -WHERE workspace_id = $1 -ORDER BY created_at DESC, id DESC -` - -func (q *Queries) ListWorkspaceInvitations(ctx context.Context, workspaceID pgtype.UUID) ([]WorkspaceInvitation, error) { - rows, err := q.db.Query(ctx, listWorkspaceInvitations, workspaceID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []WorkspaceInvitation{} - for rows.Next() { - var i WorkspaceInvitation - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.EmailNormalized, - &i.Role, - &i.TokenHash, - &i.InvitedBy, - &i.ExpiresAt, - &i.AcceptedAt, - &i.AcceptedBy, - &i.RevokedAt, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listWorkspaceMembers = `-- name: ListWorkspaceMembers :many -SELECT wm.workspace_id, wm.user_id, wm.role, wm.created_at, wm.updated_at, - u.username, u.email, u.display_name -FROM workspace_members AS wm -JOIN users AS u ON u.id = wm.user_id -WHERE wm.workspace_id = $1 -ORDER BY wm.created_at, wm.user_id -` - -type ListWorkspaceMembersRow struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - UserID pgtype.UUID `json:"user_id"` - Role string `json:"role"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` -} - -func (q *Queries) ListWorkspaceMembers(ctx context.Context, workspaceID pgtype.UUID) ([]ListWorkspaceMembersRow, error) { - rows, err := q.db.Query(ctx, listWorkspaceMembers, workspaceID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListWorkspaceMembersRow{} - for rows.Next() { - var i ListWorkspaceMembersRow - if err := rows.Scan( - &i.WorkspaceID, - &i.UserID, - &i.Role, - &i.CreatedAt, - &i.UpdatedAt, - &i.Username, - &i.Email, - &i.DisplayName, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listWorkspacesForUser = `-- name: ListWorkspacesForUser :many -SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, wm.role -FROM workspaces w -JOIN workspace_members wm ON wm.workspace_id = w.id -WHERE wm.user_id = $1 - AND w.deleted_at IS NULL -ORDER BY w.created_at, w.id -` - -type ListWorkspacesForUserRow struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Kind string `json:"kind"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Role string `json:"role"` -} - -func (q *Queries) ListWorkspacesForUser(ctx context.Context, userID pgtype.UUID) ([]ListWorkspacesForUserRow, error) { - rows, err := q.db.Query(ctx, listWorkspacesForUser, userID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListWorkspacesForUserRow{} - for rows.Next() { - var i ListWorkspacesForUserRow - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Slug, - &i.Kind, - &i.CreatedBy, - &i.CreatedAt, - &i.UpdatedAt, - &i.Role, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const revokeWorkspaceInvitation = `-- name: RevokeWorkspaceInvitation :execrows -UPDATE workspace_invitations SET revoked_at = now(), updated_at = now() -WHERE id = $1 AND accepted_at IS NULL AND revoked_at IS NULL -` - -func (q *Queries) RevokeWorkspaceInvitation(ctx context.Context, id pgtype.UUID) (int64, error) { - result, err := q.db.Exec(ctx, revokeWorkspaceInvitation, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const updateWorkspace = `-- name: UpdateWorkspace :one -UPDATE workspaces SET name = $1, updated_at = now() -WHERE id = $2 AND deleted_at IS NULL -RETURNING id, name, slug, kind, created_by, created_at, updated_at, deleted_at -` - -type UpdateWorkspaceParams struct { - Name string `json:"name"` - ID pgtype.UUID `json:"id"` -} - -func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) { - row := q.db.QueryRow(ctx, updateWorkspace, arg.Name, arg.ID) - var i Workspace - err := row.Scan( - &i.ID, - &i.Name, - &i.Slug, - &i.Kind, - &i.CreatedBy, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const upsertUser = `-- name: UpsertUser :one -INSERT INTO users ( - id, issuer, external_subject, username, email, display_name, last_seen_at -) VALUES ( - $1, $2, $3, - $4, $5, $6, now() -) -ON CONFLICT (issuer, external_subject) DO UPDATE SET - username = EXCLUDED.username, - email = EXCLUDED.email, - display_name = EXCLUDED.display_name, - last_seen_at = now(), - updated_at = now() -RETURNING id, issuer, external_subject, username, email, display_name, status, activated_at, suspended_at, last_seen_at, terms_version, privacy_version, agreements_accepted_at, deleted_at, created_at, updated_at -` - -type UpsertUserParams struct { - ID pgtype.UUID `json:"id"` - Issuer string `json:"issuer"` - ExternalSubject string `json:"external_subject"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` -} - -func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error) { - row := q.db.QueryRow(ctx, upsertUser, - arg.ID, - arg.Issuer, - arg.ExternalSubject, - arg.Username, - arg.Email, - arg.DisplayName, - ) - var i User - err := row.Scan( - &i.ID, - &i.Issuer, - &i.ExternalSubject, - &i.Username, - &i.Email, - &i.DisplayName, - &i.Status, - &i.ActivatedAt, - &i.SuspendedAt, - &i.LastSeenAt, - &i.TermsVersion, - &i.PrivacyVersion, - &i.AgreementsAcceptedAt, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const upsertWorkspaceMember = `-- name: UpsertWorkspaceMember :exec -INSERT INTO workspace_members (workspace_id, user_id, role) -VALUES ($1, $2, $3) -ON CONFLICT (workspace_id, user_id) DO UPDATE -SET role = EXCLUDED.role, updated_at = now() -` - -type UpsertWorkspaceMemberParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - UserID pgtype.UUID `json:"user_id"` - Role string `json:"role"` -} - -func (q *Queries) UpsertWorkspaceMember(ctx context.Context, arg UpsertWorkspaceMemberParams) error { - _, err := q.db.Exec(ctx, upsertWorkspaceMember, arg.WorkspaceID, arg.UserID, arg.Role) - return err -} diff --git a/internal/repository/postgres/sqlc/jobs.sql.go b/internal/repository/postgres/sqlc/jobs.sql.go deleted file mode 100644 index e10a1d6..0000000 --- a/internal/repository/postgres/sqlc/jobs.sql.go +++ /dev/null @@ -1,525 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: jobs.sql - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const appendAuditLog = `-- name: AppendAuditLog :exec -INSERT INTO audit_logs ( - id, workspace_id, actor_user_id, action, resource_type, resource_id, result, metadata -) VALUES ( - $1, $2, $3, $4, - $5, $6, $7, $8 -) -` - -type AppendAuditLogParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ActorUserID pgtype.UUID `json:"actor_user_id"` - Action string `json:"action"` - ResourceType string `json:"resource_type"` - ResourceID pgtype.UUID `json:"resource_id"` - Result string `json:"result"` - Metadata []byte `json:"metadata"` -} - -func (q *Queries) AppendAuditLog(ctx context.Context, arg AppendAuditLogParams) error { - _, err := q.db.Exec(ctx, appendAuditLog, - arg.ID, - arg.WorkspaceID, - arg.ActorUserID, - arg.Action, - arg.ResourceType, - arg.ResourceID, - arg.Result, - arg.Metadata, - ) - return err -} - -const appendOutboxEvent = `-- name: AppendOutboxEvent :exec -INSERT INTO outbox_events ( - id, workspace_id, aggregate, aggregate_id, event_type, payload, trace_context -) VALUES ( - $1, $2, $3, $4, - $5, $6, $7 -) -` - -type AppendOutboxEventParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - Aggregate string `json:"aggregate"` - AggregateID pgtype.UUID `json:"aggregate_id"` - EventType string `json:"event_type"` - Payload []byte `json:"payload"` - TraceContext []byte `json:"trace_context"` -} - -func (q *Queries) AppendOutboxEvent(ctx context.Context, arg AppendOutboxEventParams) error { - _, err := q.db.Exec(ctx, appendOutboxEvent, - arg.ID, - arg.WorkspaceID, - arg.Aggregate, - arg.AggregateID, - arg.EventType, - arg.Payload, - arg.TraceContext, - ) - return err -} - -const cancelActiveRepositorySyncJob = `-- name: CancelActiveRepositorySyncJob :one -UPDATE jobs -SET status = 'cancelled', lease_owner = NULL, lease_until = NULL, - last_error_code = 'job.cancelled', last_error_message = 'Cancelled by user', - finished_at = now(), updated_at = now() -WHERE workspace_id = $1 AND type = 'repository.sync' - AND payload->>'repositoryId' = $2::text - AND status IN ('queued', 'running') -RETURNING id, workspace_id, type, payload, status, idempotency_key, attempt, max_attempts, lease_owner, lease_until, fencing_token, run_after, trace_context, last_error_code, last_error_message, created_at, started_at, finished_at, updated_at -` - -type CancelActiveRepositorySyncJobParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - RepositoryID string `json:"repository_id"` -} - -func (q *Queries) CancelActiveRepositorySyncJob(ctx context.Context, arg CancelActiveRepositorySyncJobParams) (Job, error) { - row := q.db.QueryRow(ctx, cancelActiveRepositorySyncJob, arg.WorkspaceID, arg.RepositoryID) - var i Job - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Payload, - &i.Status, - &i.IdempotencyKey, - &i.Attempt, - &i.MaxAttempts, - &i.LeaseOwner, - &i.LeaseUntil, - &i.FencingToken, - &i.RunAfter, - &i.TraceContext, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.CreatedAt, - &i.StartedAt, - &i.FinishedAt, - &i.UpdatedAt, - ) - return i, err -} - -const cancelJob = `-- name: CancelJob :one -UPDATE jobs -SET status = 'cancelled', lease_owner = NULL, lease_until = NULL, - last_error_code = 'job.cancelled', last_error_message = 'Cancelled by user', - finished_at = now(), updated_at = now() -WHERE id = $1 AND workspace_id = $2 - AND status IN ('queued', 'running') -RETURNING id, workspace_id, type, payload, status, idempotency_key, attempt, max_attempts, lease_owner, lease_until, fencing_token, run_after, trace_context, last_error_code, last_error_message, created_at, started_at, finished_at, updated_at -` - -type CancelJobParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) CancelJob(ctx context.Context, arg CancelJobParams) (Job, error) { - row := q.db.QueryRow(ctx, cancelJob, arg.ID, arg.WorkspaceID) - var i Job - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Payload, - &i.Status, - &i.IdempotencyKey, - &i.Attempt, - &i.MaxAttempts, - &i.LeaseOwner, - &i.LeaseUntil, - &i.FencingToken, - &i.RunAfter, - &i.TraceContext, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.CreatedAt, - &i.StartedAt, - &i.FinishedAt, - &i.UpdatedAt, - ) - return i, err -} - -const claimJob = `-- name: ClaimJob :one -WITH candidate AS ( - SELECT id FROM jobs - WHERE type = ANY($3::text[]) - AND attempt < max_attempts - AND ((status = 'queued' AND run_after <= now()) - OR (status = 'running' AND lease_until < now())) - ORDER BY run_after, created_at - FOR UPDATE SKIP LOCKED - LIMIT 1 -) -UPDATE jobs AS job -SET status = 'running', lease_owner = $1, - lease_until = $2, fencing_token = fencing_token + 1, - attempt = attempt + 1, started_at = COALESCE(started_at, now()), updated_at = now() -FROM candidate -WHERE job.id = candidate.id -RETURNING job.id, job.workspace_id, job.type, job.payload, job.status, job.idempotency_key, job.attempt, job.max_attempts, job.lease_owner, job.lease_until, job.fencing_token, job.run_after, job.trace_context, job.last_error_code, job.last_error_message, job.created_at, job.started_at, job.finished_at, job.updated_at -` - -type ClaimJobParams struct { - LeaseOwner *string `json:"lease_owner"` - LeaseUntil pgtype.Timestamptz `json:"lease_until"` - Types []string `json:"types"` -} - -func (q *Queries) ClaimJob(ctx context.Context, arg ClaimJobParams) (Job, error) { - row := q.db.QueryRow(ctx, claimJob, arg.LeaseOwner, arg.LeaseUntil, arg.Types) - var i Job - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Payload, - &i.Status, - &i.IdempotencyKey, - &i.Attempt, - &i.MaxAttempts, - &i.LeaseOwner, - &i.LeaseUntil, - &i.FencingToken, - &i.RunAfter, - &i.TraceContext, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.CreatedAt, - &i.StartedAt, - &i.FinishedAt, - &i.UpdatedAt, - ) - return i, err -} - -const completeJob = `-- name: CompleteJob :execrows -UPDATE jobs SET status = 'succeeded', lease_owner = NULL, lease_until = NULL, - finished_at = now(), updated_at = now() -WHERE id = $1 AND status = 'running' - AND lease_owner = $2 AND fencing_token = $3 -` - -type CompleteJobParams struct { - ID pgtype.UUID `json:"id"` - LeaseOwner *string `json:"lease_owner"` - FencingToken int64 `json:"fencing_token"` -} - -func (q *Queries) CompleteJob(ctx context.Context, arg CompleteJobParams) (int64, error) { - result, err := q.db.Exec(ctx, completeJob, arg.ID, arg.LeaseOwner, arg.FencingToken) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const createJob = `-- name: CreateJob :one -INSERT INTO jobs ( - id, workspace_id, type, payload, status, idempotency_key, - max_attempts, run_after, trace_context -) VALUES ( - $1, $2, $3, $4, 'queued', - $5, $6, $7, $8 -) -RETURNING id, workspace_id, type, payload, status, idempotency_key, attempt, max_attempts, lease_owner, lease_until, fencing_token, run_after, trace_context, last_error_code, last_error_message, created_at, started_at, finished_at, updated_at -` - -type CreateJobParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - Type string `json:"type"` - Payload []byte `json:"payload"` - IdempotencyKey string `json:"idempotency_key"` - MaxAttempts int32 `json:"max_attempts"` - RunAfter pgtype.Timestamptz `json:"run_after"` - TraceContext []byte `json:"trace_context"` -} - -func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) (Job, error) { - row := q.db.QueryRow(ctx, createJob, - arg.ID, - arg.WorkspaceID, - arg.Type, - arg.Payload, - arg.IdempotencyKey, - arg.MaxAttempts, - arg.RunAfter, - arg.TraceContext, - ) - var i Job - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Payload, - &i.Status, - &i.IdempotencyKey, - &i.Attempt, - &i.MaxAttempts, - &i.LeaseOwner, - &i.LeaseUntil, - &i.FencingToken, - &i.RunAfter, - &i.TraceContext, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.CreatedAt, - &i.StartedAt, - &i.FinishedAt, - &i.UpdatedAt, - ) - return i, err -} - -const failJob = `-- name: FailJob :execrows -UPDATE jobs SET status = 'failed', lease_owner = NULL, lease_until = NULL, - last_error_code = $1, last_error_message = $2, - finished_at = now(), updated_at = now() -WHERE id = $3 AND status = 'running' - AND lease_owner = $4 AND fencing_token = $5 -` - -type FailJobParams struct { - ErrorCode string `json:"error_code"` - ErrorMessage string `json:"error_message"` - ID pgtype.UUID `json:"id"` - LeaseOwner *string `json:"lease_owner"` - FencingToken int64 `json:"fencing_token"` -} - -func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) { - result, err := q.db.Exec(ctx, failJob, - arg.ErrorCode, - arg.ErrorMessage, - arg.ID, - arg.LeaseOwner, - arg.FencingToken, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getJob = `-- name: GetJob :one -SELECT id, workspace_id, type, payload, status, idempotency_key, attempt, max_attempts, lease_owner, lease_until, fencing_token, run_after, trace_context, last_error_code, last_error_message, created_at, started_at, finished_at, updated_at FROM jobs WHERE id = $1 AND workspace_id = $2 -` - -type GetJobParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) GetJob(ctx context.Context, arg GetJobParams) (Job, error) { - row := q.db.QueryRow(ctx, getJob, arg.ID, arg.WorkspaceID) - var i Job - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Payload, - &i.Status, - &i.IdempotencyKey, - &i.Attempt, - &i.MaxAttempts, - &i.LeaseOwner, - &i.LeaseUntil, - &i.FencingToken, - &i.RunAfter, - &i.TraceContext, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.CreatedAt, - &i.StartedAt, - &i.FinishedAt, - &i.UpdatedAt, - ) - return i, err -} - -const listAuditLogs = `-- name: ListAuditLogs :many -SELECT id, workspace_id, actor_user_id, action, resource_type, resource_id, result, metadata, occurred_at FROM audit_logs -WHERE workspace_id = $1 - AND ($2::timestamptz IS NULL OR (occurred_at, id) < ($2, $3::uuid)) -ORDER BY occurred_at DESC, id DESC -LIMIT $4 -` - -type ListAuditLogsParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - BeforeTime pgtype.Timestamptz `json:"before_time"` - BeforeID pgtype.UUID `json:"before_id"` - PageSize int32 `json:"page_size"` -} - -func (q *Queries) ListAuditLogs(ctx context.Context, arg ListAuditLogsParams) ([]AuditLog, error) { - rows, err := q.db.Query(ctx, listAuditLogs, - arg.WorkspaceID, - arg.BeforeTime, - arg.BeforeID, - arg.PageSize, - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := []AuditLog{} - for rows.Next() { - var i AuditLog - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.ActorUserID, - &i.Action, - &i.ResourceType, - &i.ResourceID, - &i.Result, - &i.Metadata, - &i.OccurredAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listJobQueueDepths = `-- name: ListJobQueueDepths :many -SELECT type, status, count(*)::bigint AS depth -FROM jobs -WHERE type = ANY($1::text[]) AND status IN ('queued', 'running') -GROUP BY type, status -ORDER BY type, status -` - -type ListJobQueueDepthsRow struct { - Type string `json:"type"` - Status string `json:"status"` - Depth int64 `json:"depth"` -} - -func (q *Queries) ListJobQueueDepths(ctx context.Context, types []string) ([]ListJobQueueDepthsRow, error) { - rows, err := q.db.Query(ctx, listJobQueueDepths, types) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListJobQueueDepthsRow{} - for rows.Next() { - var i ListJobQueueDepthsRow - if err := rows.Scan(&i.Type, &i.Status, &i.Depth); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const lockCurrentJob = `-- name: LockCurrentJob :one -SELECT id FROM jobs -WHERE id = $1 AND status = 'running' - AND lease_owner = $2 - AND fencing_token = $3 - AND lease_until > now() -FOR UPDATE -` - -type LockCurrentJobParams struct { - ID pgtype.UUID `json:"id"` - LeaseOwner *string `json:"lease_owner"` - FencingToken int64 `json:"fencing_token"` -} - -func (q *Queries) LockCurrentJob(ctx context.Context, arg LockCurrentJobParams) (pgtype.UUID, error) { - row := q.db.QueryRow(ctx, lockCurrentJob, arg.ID, arg.LeaseOwner, arg.FencingToken) - var id pgtype.UUID - err := row.Scan(&id) - return id, err -} - -const renewJobLease = `-- name: RenewJobLease :execrows -UPDATE jobs SET lease_until = $1, updated_at = now() -WHERE id = $2 AND status = 'running' - AND lease_owner = $3 AND fencing_token = $4 -` - -type RenewJobLeaseParams struct { - LeaseUntil pgtype.Timestamptz `json:"lease_until"` - ID pgtype.UUID `json:"id"` - LeaseOwner *string `json:"lease_owner"` - FencingToken int64 `json:"fencing_token"` -} - -func (q *Queries) RenewJobLease(ctx context.Context, arg RenewJobLeaseParams) (int64, error) { - result, err := q.db.Exec(ctx, renewJobLease, - arg.LeaseUntil, - arg.ID, - arg.LeaseOwner, - arg.FencingToken, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const retryJob = `-- name: RetryJob :execrows -UPDATE jobs SET status = 'queued', lease_owner = NULL, lease_until = NULL, - run_after = $1, last_error_code = $2, - last_error_message = $3, updated_at = now() -WHERE id = $4 AND status = 'running' AND attempt < max_attempts - AND lease_owner = $5 AND fencing_token = $6 -` - -type RetryJobParams struct { - RunAfter pgtype.Timestamptz `json:"run_after"` - ErrorCode string `json:"error_code"` - ErrorMessage string `json:"error_message"` - ID pgtype.UUID `json:"id"` - LeaseOwner *string `json:"lease_owner"` - FencingToken int64 `json:"fencing_token"` -} - -func (q *Queries) RetryJob(ctx context.Context, arg RetryJobParams) (int64, error) { - result, err := q.db.Exec(ctx, retryJob, - arg.RunAfter, - arg.ErrorCode, - arg.ErrorMessage, - arg.ID, - arg.LeaseOwner, - arg.FencingToken, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/internal/repository/postgres/sqlc/models.go b/internal/repository/postgres/sqlc/models.go deleted file mode 100644 index 7d84cb9..0000000 --- a/internal/repository/postgres/sqlc/models.go +++ /dev/null @@ -1,265 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 - -package db - -import ( - "github.com/jackc/pgx/v5/pgtype" -) - -type AccountAuditLog struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` - ActorUserID pgtype.UUID `json:"actor_user_id"` - Action string `json:"action"` - Result string `json:"result"` - Provider string `json:"provider"` - RequestID string `json:"request_id"` - Metadata []byte `json:"metadata"` - OccurredAt pgtype.Timestamptz `json:"occurred_at"` -} - -type AuditLog struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ActorUserID pgtype.UUID `json:"actor_user_id"` - Action string `json:"action"` - ResourceType string `json:"resource_type"` - ResourceID pgtype.UUID `json:"resource_id"` - Result string `json:"result"` - Metadata []byte `json:"metadata"` - OccurredAt pgtype.Timestamptz `json:"occurred_at"` -} - -type ChannelInstance struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - Type string `json:"type"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - Config []byte `json:"config"` - SecretRef pgtype.UUID `json:"secret_ref"` - ConfigVersion int64 `json:"config_version"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` -} - -type ChannelLease struct { - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - Owner string `json:"owner"` - LeaseUntil pgtype.Timestamptz `json:"lease_until"` - FencingToken int64 `json:"fencing_token"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type ChannelRuntimeStatus struct { - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - State string `json:"state"` - BackendInstanceID string `json:"backend_instance_id"` - FencingToken int64 `json:"fencing_token"` - LastConnectedAt pgtype.Timestamptz `json:"last_connected_at"` - LastErrorCode string `json:"last_error_code"` - LastErrorMessage string `json:"last_error_message"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type ImConversation struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - ExternalID string `json:"external_id"` - Type string `json:"type"` - Title string `json:"title"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type ImMessage struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - ConversationID pgtype.UUID `json:"conversation_id"` - SenderID pgtype.UUID `json:"sender_id"` - ExternalMessageID string `json:"external_message_id"` - Content []byte `json:"content"` - OccurredAt pgtype.Timestamptz `json:"occurred_at"` - ReceivedAt pgtype.Timestamptz `json:"received_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -type ImSender struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ChannelType string `json:"channel_type"` - CanonicalID string `json:"canonical_id"` - DisplayName string `json:"display_name"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type InboxEvent struct { - ChannelInstanceID pgtype.UUID `json:"channel_instance_id"` - ExternalEventID string `json:"external_event_id"` - PayloadHash string `json:"payload_hash"` - ReceivedAt pgtype.Timestamptz `json:"received_at"` -} - -type Job struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - Type string `json:"type"` - Payload []byte `json:"payload"` - Status string `json:"status"` - IdempotencyKey string `json:"idempotency_key"` - Attempt int32 `json:"attempt"` - MaxAttempts int32 `json:"max_attempts"` - LeaseOwner *string `json:"lease_owner"` - LeaseUntil pgtype.Timestamptz `json:"lease_until"` - FencingToken int64 `json:"fencing_token"` - RunAfter pgtype.Timestamptz `json:"run_after"` - TraceContext []byte `json:"trace_context"` - LastErrorCode string `json:"last_error_code"` - LastErrorMessage string `json:"last_error_message"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - StartedAt pgtype.Timestamptz `json:"started_at"` - FinishedAt pgtype.Timestamptz `json:"finished_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type MutationControl struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Operation string `json:"operation"` - ResourceKey string `json:"resource_key"` - IdempotencyKey string `json:"idempotency_key"` - RequestHash string `json:"request_hash"` - ResourceID pgtype.UUID `json:"resource_id"` - Status string `json:"status"` - ActiveUntil pgtype.Timestamptz `json:"active_until"` - NextAllowedAt pgtype.Timestamptz `json:"next_allowed_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type OutboxEvent struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - Aggregate string `json:"aggregate"` - AggregateID pgtype.UUID `json:"aggregate_id"` - EventType string `json:"event_type"` - Payload []byte `json:"payload"` - TraceContext []byte `json:"trace_context"` - LeaseOwner *string `json:"lease_owner"` - LeaseUntil pgtype.Timestamptz `json:"lease_until"` - Attempt int32 `json:"attempt"` - NextAttemptAt pgtype.Timestamptz `json:"next_attempt_at"` - LastErrorCode string `json:"last_error_code"` - LastErrorMessage string `json:"last_error_message"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - PublishedAt pgtype.Timestamptz `json:"published_at"` -} - -type Repository struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ConnectionID pgtype.UUID `json:"connection_id"` - Name string `json:"name"` - CloneUrl string `json:"clone_url"` - NormalizedUrl string `json:"normalized_url"` - Ref string `json:"ref"` - CurrentCommitSha string `json:"current_commit_sha"` - State string `json:"state"` - LastErrorCode string `json:"last_error_code"` - LastErrorMessage string `json:"last_error_message"` - SyncedAt pgtype.Timestamptz `json:"synced_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` -} - -type RepositorySyncControl struct { - RepositoryID pgtype.UUID `json:"repository_id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - NextAllowedAt pgtype.Timestamptz `json:"next_allowed_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type ScmConnection struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - Type string `json:"type"` - Name string `json:"name"` - BaseUrl string `json:"base_url"` - AuthType string `json:"auth_type"` - SecretRef pgtype.UUID `json:"secret_ref"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` -} - -type Secret struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ResourceType string `json:"resource_type"` - ResourceID pgtype.UUID `json:"resource_id"` - Ciphertext []byte `json:"ciphertext"` - Nonce []byte `json:"nonce"` - WrappedKey []byte `json:"wrapped_key"` - WrappedKeyNonce []byte `json:"wrapped_key_nonce"` - KeyVersion int32 `json:"key_version"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type User struct { - ID pgtype.UUID `json:"id"` - Issuer string `json:"issuer"` - ExternalSubject string `json:"external_subject"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Status string `json:"status"` - ActivatedAt pgtype.Timestamptz `json:"activated_at"` - SuspendedAt pgtype.Timestamptz `json:"suspended_at"` - LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` - TermsVersion string `json:"terms_version"` - PrivacyVersion string `json:"privacy_version"` - AgreementsAcceptedAt pgtype.Timestamptz `json:"agreements_accepted_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type Workspace struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Kind string `json:"kind"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` -} - -type WorkspaceInvitation struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - EmailNormalized string `json:"email_normalized"` - Role string `json:"role"` - TokenHash []byte `json:"token_hash"` - InvitedBy pgtype.UUID `json:"invited_by"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` - AcceptedAt pgtype.Timestamptz `json:"accepted_at"` - AcceptedBy pgtype.UUID `json:"accepted_by"` - RevokedAt pgtype.Timestamptz `json:"revoked_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type WorkspaceMember struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - UserID pgtype.UUID `json:"user_id"` - Role string `json:"role"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} diff --git a/internal/repository/postgres/sqlc/outbox.sql.go b/internal/repository/postgres/sqlc/outbox.sql.go deleted file mode 100644 index 72c132b..0000000 --- a/internal/repository/postgres/sqlc/outbox.sql.go +++ /dev/null @@ -1,123 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: outbox.sql - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const claimOutboxEvent = `-- name: ClaimOutboxEvent :one -WITH candidate AS ( - SELECT id - FROM outbox_events - WHERE published_at IS NULL AND next_attempt_at <= now() - AND (lease_until IS NULL OR lease_until < now()) - ORDER BY next_attempt_at, created_at - FOR UPDATE SKIP LOCKED - LIMIT 1 -) -UPDATE outbox_events AS event -SET lease_owner = $1, lease_until = $2, - attempt = attempt + 1 -FROM candidate -WHERE event.id = candidate.id -RETURNING event.id, event.workspace_id, event.aggregate, event.aggregate_id, event.event_type, event.payload, event.trace_context, event.lease_owner, event.lease_until, event.attempt, event.next_attempt_at, event.last_error_code, event.last_error_message, event.created_at, event.published_at -` - -type ClaimOutboxEventParams struct { - LeaseOwner *string `json:"lease_owner"` - LeaseUntil pgtype.Timestamptz `json:"lease_until"` -} - -func (q *Queries) ClaimOutboxEvent(ctx context.Context, arg ClaimOutboxEventParams) (OutboxEvent, error) { - row := q.db.QueryRow(ctx, claimOutboxEvent, arg.LeaseOwner, arg.LeaseUntil) - var i OutboxEvent - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Aggregate, - &i.AggregateID, - &i.EventType, - &i.Payload, - &i.TraceContext, - &i.LeaseOwner, - &i.LeaseUntil, - &i.Attempt, - &i.NextAttemptAt, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.CreatedAt, - &i.PublishedAt, - ) - return i, err -} - -const completeOutboxEvent = `-- name: CompleteOutboxEvent :execrows -UPDATE outbox_events -SET published_at = now(), lease_owner = NULL, lease_until = NULL, - last_error_code = '', last_error_message = '' -WHERE id = $1 AND published_at IS NULL - AND lease_owner = $2 AND attempt = $3 -` - -type CompleteOutboxEventParams struct { - ID pgtype.UUID `json:"id"` - LeaseOwner *string `json:"lease_owner"` - Attempt int32 `json:"attempt"` -} - -func (q *Queries) CompleteOutboxEvent(ctx context.Context, arg CompleteOutboxEventParams) (int64, error) { - result, err := q.db.Exec(ctx, completeOutboxEvent, arg.ID, arg.LeaseOwner, arg.Attempt) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const countOutboxBacklog = `-- name: CountOutboxBacklog :one -SELECT count(*) FROM outbox_events WHERE published_at IS NULL -` - -func (q *Queries) CountOutboxBacklog(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countOutboxBacklog) - var count int64 - err := row.Scan(&count) - return count, err -} - -const retryOutboxEvent = `-- name: RetryOutboxEvent :execrows -UPDATE outbox_events -SET lease_owner = NULL, lease_until = NULL, next_attempt_at = $1, - last_error_code = $2, last_error_message = $3 -WHERE id = $4 AND published_at IS NULL - AND lease_owner = $5 AND attempt = $6 -` - -type RetryOutboxEventParams struct { - NextAttemptAt pgtype.Timestamptz `json:"next_attempt_at"` - ErrorCode string `json:"error_code"` - ErrorMessage string `json:"error_message"` - ID pgtype.UUID `json:"id"` - LeaseOwner *string `json:"lease_owner"` - Attempt int32 `json:"attempt"` -} - -func (q *Queries) RetryOutboxEvent(ctx context.Context, arg RetryOutboxEventParams) (int64, error) { - result, err := q.db.Exec(ctx, retryOutboxEvent, - arg.NextAttemptAt, - arg.ErrorCode, - arg.ErrorMessage, - arg.ID, - arg.LeaseOwner, - arg.Attempt, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/internal/repository/postgres/sqlc/querier.go b/internal/repository/postgres/sqlc/querier.go deleted file mode 100644 index c3d6af7..0000000 --- a/internal/repository/postgres/sqlc/querier.go +++ /dev/null @@ -1,90 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -type Querier interface { - AcceptWorkspaceInvitation(ctx context.Context, arg AcceptWorkspaceInvitationParams) (int64, error) - AcquireChannelLease(ctx context.Context, arg AcquireChannelLeaseParams) (ChannelLease, error) - ActivateUser(ctx context.Context, arg ActivateUserParams) (User, error) - AddWorkspaceMember(ctx context.Context, arg AddWorkspaceMemberParams) (WorkspaceMember, error) - AppendAccountAudit(ctx context.Context, arg AppendAccountAuditParams) error - AppendAuditLog(ctx context.Context, arg AppendAuditLogParams) error - AppendOutboxEvent(ctx context.Context, arg AppendOutboxEventParams) error - CancelActiveRepositorySyncJob(ctx context.Context, arg CancelActiveRepositorySyncJobParams) (Job, error) - CancelJob(ctx context.Context, arg CancelJobParams) (Job, error) - CancelRepositorySyncJobs(ctx context.Context, arg CancelRepositorySyncJobsParams) error - ClaimJob(ctx context.Context, arg ClaimJobParams) (Job, error) - ClaimOutboxEvent(ctx context.Context, arg ClaimOutboxEventParams) (OutboxEvent, error) - CompleteJob(ctx context.Context, arg CompleteJobParams) (int64, error) - CompleteOutboxEvent(ctx context.Context, arg CompleteOutboxEventParams) (int64, error) - CountOutboxBacklog(ctx context.Context) (int64, error) - CreateChannelInstance(ctx context.Context, arg CreateChannelInstanceParams) (ChannelInstance, error) - CreateJob(ctx context.Context, arg CreateJobParams) (Job, error) - CreateRepository(ctx context.Context, arg CreateRepositoryParams) (Repository, error) - CreateSCMConnection(ctx context.Context, arg CreateSCMConnectionParams) (ScmConnection, error) - CreateSecret(ctx context.Context, arg CreateSecretParams) (Secret, error) - CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) - CreateWorkspaceInvitation(ctx context.Context, arg CreateWorkspaceInvitationParams) (WorkspaceInvitation, error) - DeleteSecret(ctx context.Context, arg DeleteSecretParams) error - DeleteWorkspaceMember(ctx context.Context, arg DeleteWorkspaceMemberParams) (int64, error) - FailJob(ctx context.Context, arg FailJobParams) (int64, error) - GetChannelInstance(ctx context.Context, arg GetChannelInstanceParams) (ChannelInstance, error) - GetChannelRuntimeStatus(ctx context.Context, channelInstanceID pgtype.UUID) (ChannelRuntimeStatus, error) - GetJob(ctx context.Context, arg GetJobParams) (Job, error) - GetPersonalWorkspaceForUser(ctx context.Context, userID pgtype.UUID) (Workspace, error) - GetRepository(ctx context.Context, arg GetRepositoryParams) (Repository, error) - GetSCMConnection(ctx context.Context, arg GetSCMConnectionParams) (ScmConnection, error) - GetSecret(ctx context.Context, arg GetSecretParams) (Secret, error) - GetUserByExternalIdentity(ctx context.Context, arg GetUserByExternalIdentityParams) (User, error) - GetUserForUpdate(ctx context.Context, id pgtype.UUID) (User, error) - GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (WorkspaceInvitation, error) - GetWorkspaceMembership(ctx context.Context, arg GetWorkspaceMembershipParams) (GetWorkspaceMembershipRow, error) - GetWorkspaceOverview(ctx context.Context, targetWorkspaceID pgtype.UUID) (GetWorkspaceOverviewRow, error) - InsertIMMessage(ctx context.Context, arg InsertIMMessageParams) (ImMessage, error) - InsertInboxEvent(ctx context.Context, arg InsertInboxEventParams) (string, error) - ListAuditLogs(ctx context.Context, arg ListAuditLogsParams) ([]AuditLog, error) - ListChannelInstances(ctx context.Context, workspaceID pgtype.UUID) ([]ChannelInstance, error) - ListEnabledChannelInstances(ctx context.Context) ([]ChannelInstance, error) - ListIMConversations(ctx context.Context, arg ListIMConversationsParams) ([]ListIMConversationsRow, error) - ListIMMessages(ctx context.Context, arg ListIMMessagesParams) ([]ListIMMessagesRow, error) - ListJobQueueDepths(ctx context.Context, types []string) ([]ListJobQueueDepthsRow, error) - ListRepositories(ctx context.Context, workspaceID pgtype.UUID) ([]Repository, error) - ListSCMConnections(ctx context.Context, workspaceID pgtype.UUID) ([]ScmConnection, error) - ListWorkspaceInvitations(ctx context.Context, workspaceID pgtype.UUID) ([]WorkspaceInvitation, error) - ListWorkspaceMembers(ctx context.Context, workspaceID pgtype.UUID) ([]ListWorkspaceMembersRow, error) - ListWorkspacesForUser(ctx context.Context, userID pgtype.UUID) ([]ListWorkspacesForUserRow, error) - LockCurrentJob(ctx context.Context, arg LockCurrentJobParams) (pgtype.UUID, error) - MarkRepositoryFailed(ctx context.Context, arg MarkRepositoryFailedParams) (int64, error) - MarkRepositoryReady(ctx context.Context, arg MarkRepositoryReadyParams) (int64, error) - MarkRepositorySyncing(ctx context.Context, arg MarkRepositorySyncingParams) (int64, error) - ReleaseChannelLease(ctx context.Context, arg ReleaseChannelLeaseParams) error - RenewChannelLease(ctx context.Context, arg RenewChannelLeaseParams) (int64, error) - RenewJobLease(ctx context.Context, arg RenewJobLeaseParams) (int64, error) - ReserveRepositorySyncCooldown(ctx context.Context, arg ReserveRepositorySyncCooldownParams) (pgtype.UUID, error) - RetryJob(ctx context.Context, arg RetryJobParams) (int64, error) - RetryOutboxEvent(ctx context.Context, arg RetryOutboxEventParams) (int64, error) - RevokeWorkspaceInvitation(ctx context.Context, id pgtype.UUID) (int64, error) - SetChannelEnabled(ctx context.Context, arg SetChannelEnabledParams) (ChannelInstance, error) - SetChannelRuntimeStatus(ctx context.Context, arg SetChannelRuntimeStatusParams) (int64, error) - SetRepositoryRef(ctx context.Context, arg SetRepositoryRefParams) (int64, error) - SoftDeleteChannel(ctx context.Context, arg SoftDeleteChannelParams) (ChannelInstance, error) - SoftDeleteRepository(ctx context.Context, arg SoftDeleteRepositoryParams) (Repository, error) - SoftDeleteSCMConnection(ctx context.Context, arg SoftDeleteSCMConnectionParams) (ScmConnection, error) - UpdateChannelInstance(ctx context.Context, arg UpdateChannelInstanceParams) (ChannelInstance, error) - UpdateSCMConnection(ctx context.Context, arg UpdateSCMConnectionParams) (ScmConnection, error) - UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) - UpsertIMConversation(ctx context.Context, arg UpsertIMConversationParams) (ImConversation, error) - UpsertIMSender(ctx context.Context, arg UpsertIMSenderParams) (ImSender, error) - UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error) - UpsertWorkspaceMember(ctx context.Context, arg UpsertWorkspaceMemberParams) error -} - -var _ Querier = (*Queries)(nil) diff --git a/internal/repository/postgres/sqlc/repositories.sql.go b/internal/repository/postgres/sqlc/repositories.sql.go deleted file mode 100644 index afa3b5c..0000000 --- a/internal/repository/postgres/sqlc/repositories.sql.go +++ /dev/null @@ -1,508 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: repositories.sql - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const cancelRepositorySyncJobs = `-- name: CancelRepositorySyncJobs :exec -UPDATE jobs -SET status = 'cancelled', lease_owner = NULL, lease_until = NULL, - last_error_code = 'repository.deleted', last_error_message = 'Repository was deleted', - finished_at = now(), updated_at = now() -WHERE workspace_id = $1 AND type = 'repository.sync' - AND payload->>'repositoryId' = $2::text - AND status IN ('queued', 'running') -` - -type CancelRepositorySyncJobsParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - RepositoryID string `json:"repository_id"` -} - -func (q *Queries) CancelRepositorySyncJobs(ctx context.Context, arg CancelRepositorySyncJobsParams) error { - _, err := q.db.Exec(ctx, cancelRepositorySyncJobs, arg.WorkspaceID, arg.RepositoryID) - return err -} - -const createRepository = `-- name: CreateRepository :one -INSERT INTO repositories ( - id, workspace_id, connection_id, name, clone_url, normalized_url, - ref, state -) VALUES ( - $1, $2, $3, $4, - $5, $6, $7, 'pending' -) -RETURNING id, workspace_id, connection_id, name, clone_url, normalized_url, ref, current_commit_sha, state, last_error_code, last_error_message, synced_at, created_at, updated_at, deleted_at -` - -type CreateRepositoryParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ConnectionID pgtype.UUID `json:"connection_id"` - Name string `json:"name"` - CloneUrl string `json:"clone_url"` - NormalizedUrl string `json:"normalized_url"` - Ref string `json:"ref"` -} - -func (q *Queries) CreateRepository(ctx context.Context, arg CreateRepositoryParams) (Repository, error) { - row := q.db.QueryRow(ctx, createRepository, - arg.ID, - arg.WorkspaceID, - arg.ConnectionID, - arg.Name, - arg.CloneUrl, - arg.NormalizedUrl, - arg.Ref, - ) - var i Repository - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.ConnectionID, - &i.Name, - &i.CloneUrl, - &i.NormalizedUrl, - &i.Ref, - &i.CurrentCommitSha, - &i.State, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.SyncedAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const createSCMConnection = `-- name: CreateSCMConnection :one -INSERT INTO scm_connections (id, workspace_id, type, name, base_url, auth_type, secret_ref) -VALUES ($1, $2, $3, $4, - $5, $6, $7) -RETURNING id, workspace_id, type, name, base_url, auth_type, secret_ref, created_at, updated_at, deleted_at -` - -type CreateSCMConnectionParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - Type string `json:"type"` - Name string `json:"name"` - BaseUrl string `json:"base_url"` - AuthType string `json:"auth_type"` - SecretRef pgtype.UUID `json:"secret_ref"` -} - -func (q *Queries) CreateSCMConnection(ctx context.Context, arg CreateSCMConnectionParams) (ScmConnection, error) { - row := q.db.QueryRow(ctx, createSCMConnection, - arg.ID, - arg.WorkspaceID, - arg.Type, - arg.Name, - arg.BaseUrl, - arg.AuthType, - arg.SecretRef, - ) - var i ScmConnection - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.BaseUrl, - &i.AuthType, - &i.SecretRef, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const getRepository = `-- name: GetRepository :one -SELECT id, workspace_id, connection_id, name, clone_url, normalized_url, ref, current_commit_sha, state, last_error_code, last_error_message, synced_at, created_at, updated_at, deleted_at FROM repositories -WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL -` - -type GetRepositoryParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) GetRepository(ctx context.Context, arg GetRepositoryParams) (Repository, error) { - row := q.db.QueryRow(ctx, getRepository, arg.ID, arg.WorkspaceID) - var i Repository - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.ConnectionID, - &i.Name, - &i.CloneUrl, - &i.NormalizedUrl, - &i.Ref, - &i.CurrentCommitSha, - &i.State, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.SyncedAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const getSCMConnection = `-- name: GetSCMConnection :one -SELECT id, workspace_id, type, name, base_url, auth_type, secret_ref, created_at, updated_at, deleted_at FROM scm_connections -WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL -` - -type GetSCMConnectionParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) GetSCMConnection(ctx context.Context, arg GetSCMConnectionParams) (ScmConnection, error) { - row := q.db.QueryRow(ctx, getSCMConnection, arg.ID, arg.WorkspaceID) - var i ScmConnection - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.BaseUrl, - &i.AuthType, - &i.SecretRef, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const getWorkspaceOverview = `-- name: GetWorkspaceOverview :one -SELECT - (SELECT count(*) FROM repositories AS repository - WHERE repository.workspace_id = $1 AND repository.deleted_at IS NULL)::bigint AS repository_count, - (SELECT count(*) FROM channel_instances AS channel - WHERE channel.workspace_id = $1 AND channel.enabled = true AND channel.deleted_at IS NULL)::bigint AS active_channel_count, - (SELECT count(*) FROM jobs AS job - WHERE job.workspace_id = $1 AND job.status = 'failed')::bigint AS failed_job_count -` - -type GetWorkspaceOverviewRow struct { - RepositoryCount int64 `json:"repository_count"` - ActiveChannelCount int64 `json:"active_channel_count"` - FailedJobCount int64 `json:"failed_job_count"` -} - -func (q *Queries) GetWorkspaceOverview(ctx context.Context, targetWorkspaceID pgtype.UUID) (GetWorkspaceOverviewRow, error) { - row := q.db.QueryRow(ctx, getWorkspaceOverview, targetWorkspaceID) - var i GetWorkspaceOverviewRow - err := row.Scan(&i.RepositoryCount, &i.ActiveChannelCount, &i.FailedJobCount) - return i, err -} - -const listRepositories = `-- name: ListRepositories :many -SELECT id, workspace_id, connection_id, name, clone_url, normalized_url, ref, current_commit_sha, state, last_error_code, last_error_message, synced_at, created_at, updated_at, deleted_at FROM repositories -WHERE workspace_id = $1 AND deleted_at IS NULL -ORDER BY created_at DESC, id DESC -` - -func (q *Queries) ListRepositories(ctx context.Context, workspaceID pgtype.UUID) ([]Repository, error) { - rows, err := q.db.Query(ctx, listRepositories, workspaceID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Repository{} - for rows.Next() { - var i Repository - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.ConnectionID, - &i.Name, - &i.CloneUrl, - &i.NormalizedUrl, - &i.Ref, - &i.CurrentCommitSha, - &i.State, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.SyncedAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listSCMConnections = `-- name: ListSCMConnections :many -SELECT id, workspace_id, type, name, base_url, auth_type, secret_ref, created_at, updated_at, deleted_at FROM scm_connections -WHERE workspace_id = $1 AND deleted_at IS NULL -ORDER BY created_at DESC, id DESC -` - -func (q *Queries) ListSCMConnections(ctx context.Context, workspaceID pgtype.UUID) ([]ScmConnection, error) { - rows, err := q.db.Query(ctx, listSCMConnections, workspaceID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ScmConnection{} - for rows.Next() { - var i ScmConnection - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.BaseUrl, - &i.AuthType, - &i.SecretRef, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const markRepositoryFailed = `-- name: MarkRepositoryFailed :execrows -UPDATE repositories SET state = 'failed', last_error_code = $1, - last_error_message = $2, updated_at = now() -WHERE id = $3 AND workspace_id = $4 AND deleted_at IS NULL -` - -type MarkRepositoryFailedParams struct { - ErrorCode string `json:"error_code"` - ErrorMessage string `json:"error_message"` - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) MarkRepositoryFailed(ctx context.Context, arg MarkRepositoryFailedParams) (int64, error) { - result, err := q.db.Exec(ctx, markRepositoryFailed, - arg.ErrorCode, - arg.ErrorMessage, - arg.ID, - arg.WorkspaceID, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const markRepositoryReady = `-- name: MarkRepositoryReady :execrows -UPDATE repositories SET state = 'ready', current_commit_sha = $1, synced_at = now(), - last_error_code = '', last_error_message = '', updated_at = now() -WHERE id = $2 AND workspace_id = $3 AND deleted_at IS NULL -` - -type MarkRepositoryReadyParams struct { - CommitSha string `json:"commit_sha"` - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) MarkRepositoryReady(ctx context.Context, arg MarkRepositoryReadyParams) (int64, error) { - result, err := q.db.Exec(ctx, markRepositoryReady, arg.CommitSha, arg.ID, arg.WorkspaceID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const markRepositorySyncing = `-- name: MarkRepositorySyncing :execrows -UPDATE repositories SET state = 'syncing', last_error_code = '', last_error_message = '', updated_at = now() -WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL -` - -type MarkRepositorySyncingParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) MarkRepositorySyncing(ctx context.Context, arg MarkRepositorySyncingParams) (int64, error) { - result, err := q.db.Exec(ctx, markRepositorySyncing, arg.ID, arg.WorkspaceID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const reserveRepositorySyncCooldown = `-- name: ReserveRepositorySyncCooldown :one -INSERT INTO repository_sync_controls (repository_id, workspace_id, next_allowed_at) -VALUES ($1, $2, $3) -ON CONFLICT (repository_id) DO UPDATE -SET next_allowed_at = EXCLUDED.next_allowed_at, updated_at = now() -WHERE repository_sync_controls.workspace_id = EXCLUDED.workspace_id - AND repository_sync_controls.next_allowed_at <= now() -RETURNING repository_id -` - -type ReserveRepositorySyncCooldownParams struct { - RepositoryID pgtype.UUID `json:"repository_id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - NextAllowedAt pgtype.Timestamptz `json:"next_allowed_at"` -} - -func (q *Queries) ReserveRepositorySyncCooldown(ctx context.Context, arg ReserveRepositorySyncCooldownParams) (pgtype.UUID, error) { - row := q.db.QueryRow(ctx, reserveRepositorySyncCooldown, arg.RepositoryID, arg.WorkspaceID, arg.NextAllowedAt) - var repository_id pgtype.UUID - err := row.Scan(&repository_id) - return repository_id, err -} - -const setRepositoryRef = `-- name: SetRepositoryRef :execrows -UPDATE repositories SET ref = $1, updated_at = now() -WHERE id = $2 AND workspace_id = $3 AND deleted_at IS NULL -` - -type SetRepositoryRefParams struct { - Ref string `json:"ref"` - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) SetRepositoryRef(ctx context.Context, arg SetRepositoryRefParams) (int64, error) { - result, err := q.db.Exec(ctx, setRepositoryRef, arg.Ref, arg.ID, arg.WorkspaceID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const softDeleteRepository = `-- name: SoftDeleteRepository :one -UPDATE repositories -SET state = 'deleting', deleted_at = now(), updated_at = now() -WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL -RETURNING id, workspace_id, connection_id, name, clone_url, normalized_url, ref, current_commit_sha, state, last_error_code, last_error_message, synced_at, created_at, updated_at, deleted_at -` - -type SoftDeleteRepositoryParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) SoftDeleteRepository(ctx context.Context, arg SoftDeleteRepositoryParams) (Repository, error) { - row := q.db.QueryRow(ctx, softDeleteRepository, arg.ID, arg.WorkspaceID) - var i Repository - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.ConnectionID, - &i.Name, - &i.CloneUrl, - &i.NormalizedUrl, - &i.Ref, - &i.CurrentCommitSha, - &i.State, - &i.LastErrorCode, - &i.LastErrorMessage, - &i.SyncedAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const softDeleteSCMConnection = `-- name: SoftDeleteSCMConnection :one -UPDATE scm_connections AS connection -SET secret_ref = NULL, deleted_at = now(), updated_at = now() -WHERE connection.id = $1 AND connection.workspace_id = $2 - AND connection.deleted_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM repositories AS repository - WHERE repository.connection_id = connection.id AND repository.deleted_at IS NULL - ) -RETURNING connection.id, connection.workspace_id, connection.type, connection.name, connection.base_url, connection.auth_type, connection.secret_ref, connection.created_at, connection.updated_at, connection.deleted_at -` - -type SoftDeleteSCMConnectionParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) SoftDeleteSCMConnection(ctx context.Context, arg SoftDeleteSCMConnectionParams) (ScmConnection, error) { - row := q.db.QueryRow(ctx, softDeleteSCMConnection, arg.ID, arg.WorkspaceID) - var i ScmConnection - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.BaseUrl, - &i.AuthType, - &i.SecretRef, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} - -const updateSCMConnection = `-- name: UpdateSCMConnection :one -UPDATE scm_connections -SET name = $1, base_url = $2, auth_type = $3, - secret_ref = $4, updated_at = now() -WHERE id = $5 AND workspace_id = $6 AND deleted_at IS NULL -RETURNING id, workspace_id, type, name, base_url, auth_type, secret_ref, created_at, updated_at, deleted_at -` - -type UpdateSCMConnectionParams struct { - Name string `json:"name"` - BaseUrl string `json:"base_url"` - AuthType string `json:"auth_type"` - SecretRef pgtype.UUID `json:"secret_ref"` - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) UpdateSCMConnection(ctx context.Context, arg UpdateSCMConnectionParams) (ScmConnection, error) { - row := q.db.QueryRow(ctx, updateSCMConnection, - arg.Name, - arg.BaseUrl, - arg.AuthType, - arg.SecretRef, - arg.ID, - arg.WorkspaceID, - ) - var i ScmConnection - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Name, - &i.BaseUrl, - &i.AuthType, - &i.SecretRef, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeletedAt, - ) - return i, err -} diff --git a/internal/repository/postgres/sqlc/secrets.sql.go b/internal/repository/postgres/sqlc/secrets.sql.go deleted file mode 100644 index 87036ac..0000000 --- a/internal/repository/postgres/sqlc/secrets.sql.go +++ /dev/null @@ -1,109 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: secrets.sql - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createSecret = `-- name: CreateSecret :one -INSERT INTO secrets ( - id, workspace_id, resource_type, resource_id, ciphertext, nonce, - wrapped_key, wrapped_key_nonce, key_version -) VALUES ( - $1, $2, $3, $4, - $5, $6, $7, - $8, $9 -) -RETURNING id, workspace_id, resource_type, resource_id, ciphertext, nonce, wrapped_key, wrapped_key_nonce, key_version, created_at, updated_at -` - -type CreateSecretParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ResourceType string `json:"resource_type"` - ResourceID pgtype.UUID `json:"resource_id"` - Ciphertext []byte `json:"ciphertext"` - Nonce []byte `json:"nonce"` - WrappedKey []byte `json:"wrapped_key"` - WrappedKeyNonce []byte `json:"wrapped_key_nonce"` - KeyVersion int32 `json:"key_version"` -} - -func (q *Queries) CreateSecret(ctx context.Context, arg CreateSecretParams) (Secret, error) { - row := q.db.QueryRow(ctx, createSecret, - arg.ID, - arg.WorkspaceID, - arg.ResourceType, - arg.ResourceID, - arg.Ciphertext, - arg.Nonce, - arg.WrappedKey, - arg.WrappedKeyNonce, - arg.KeyVersion, - ) - var i Secret - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.ResourceType, - &i.ResourceID, - &i.Ciphertext, - &i.Nonce, - &i.WrappedKey, - &i.WrappedKeyNonce, - &i.KeyVersion, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const deleteSecret = `-- name: DeleteSecret :exec -DELETE FROM secrets -WHERE id = $1 AND workspace_id = $2 -` - -type DeleteSecretParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) DeleteSecret(ctx context.Context, arg DeleteSecretParams) error { - _, err := q.db.Exec(ctx, deleteSecret, arg.ID, arg.WorkspaceID) - return err -} - -const getSecret = `-- name: GetSecret :one -SELECT id, workspace_id, resource_type, resource_id, ciphertext, nonce, wrapped_key, wrapped_key_nonce, key_version, created_at, updated_at FROM secrets -WHERE id = $1 AND workspace_id = $2 -` - -type GetSecretParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) GetSecret(ctx context.Context, arg GetSecretParams) (Secret, error) { - row := q.db.QueryRow(ctx, getSecret, arg.ID, arg.WorkspaceID) - var i Secret - err := row.Scan( - &i.ID, - &i.WorkspaceID, - &i.ResourceType, - &i.ResourceID, - &i.Ciphertext, - &i.Nonce, - &i.WrappedKey, - &i.WrappedKeyNonce, - &i.KeyVersion, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} diff --git a/internal/repository/postgres/store.go b/internal/repository/postgres/store.go deleted file mode 100644 index 4c57619..0000000 --- a/internal/repository/postgres/store.go +++ /dev/null @@ -1,79 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "time" - - "github.com/exaring/otelpgx" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/mooncode-ai/mooncode/internal/config" - db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc" - "github.com/rs/zerolog" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" -) - -type Store struct { - pool *pgxpool.Pool - queries *db.Queries - propagator propagation.TextMapPropagator -} - -func NewStore(cfg config.Config, logger zerolog.Logger, tracerProvider trace.TracerProvider, propagator propagation.TextMapPropagator) (*Store, error) { - if cfg.Database.AutoMigrate { - if err := MigrateUp(context.Background(), cfg.Database); err != nil { - return nil, err - } - logger.Info().Str("operation", "database.migrate").Msg("PostgreSQL migrations are current") - } - - poolConfig, err := pgxpool.ParseConfig(cfg.Database.URL) - if err != nil { - return nil, fmt.Errorf("parse database URL: %w", err) - } - poolConfig.MaxConns = cfg.Database.MaxConnections - poolConfig.MinConns = cfg.Database.MinConnections - poolConfig.MaxConnLifetime = time.Hour - poolConfig.MaxConnIdleTime = 15 * time.Minute - poolConfig.HealthCheckPeriod = cfg.Database.HealthInterval - poolConfig.ConnConfig.Tracer = otelpgx.NewTracer( - otelpgx.WithTracerProvider(tracerProvider), - otelpgx.WithDisableSQLStatementInAttributes(), - otelpgx.WithDisableConnectionDetailsInAttributes(), - otelpgx.WithTrimSQLInSpanName(), - ) - - connectCtx, cancel := context.WithTimeout(context.Background(), cfg.Database.ConnectTimeout) - defer cancel() - pool, err := pgxpool.NewWithConfig(connectCtx, poolConfig) - if err != nil { - return nil, fmt.Errorf("create PostgreSQL pool: %w", err) - } - if err := pool.Ping(connectCtx); err != nil { - pool.Close() - return nil, fmt.Errorf("connect to PostgreSQL: %w", err) - } - return &Store{pool: pool, queries: db.New(pool), propagator: propagator}, nil -} - -func (s *Store) Check(ctx context.Context) error { - return s.pool.Ping(ctx) -} - -func (s *Store) Close() { - s.pool.Close() -} - -func (s *Store) Pool() *pgxpool.Pool { - return s.pool -} - -func (s *Store) Queries() *db.Queries { - return s.queries -} - -func (s *Store) Begin(ctx context.Context) (pgx.Tx, error) { - return s.pool.Begin(ctx) -} diff --git a/internal/repository/postgres/trace.go b/internal/repository/postgres/trace.go deleted file mode 100644 index ce2e42d..0000000 --- a/internal/repository/postgres/trace.go +++ /dev/null @@ -1,21 +0,0 @@ -package postgres - -import ( - "context" - "encoding/json" - - "go.opentelemetry.io/otel/propagation" -) - -func encodeTraceContext(ctx context.Context, propagator propagation.TextMapPropagator) []byte { - if propagator == nil { - return []byte(`{}`) - } - carrier := propagation.MapCarrier{} - propagator.Inject(ctx, carrier) - encoded, err := json.Marshal(carrier) - if err != nil { - return []byte(`{}`) - } - return encoded -} diff --git a/internal/repository/repository.go b/internal/repository/repository.go deleted file mode 100644 index 51367a1..0000000 --- a/internal/repository/repository.go +++ /dev/null @@ -1,62 +0,0 @@ -package repository - -import ( - "context" - "errors" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" -) - -var ErrLeaseLost = errors.New("repository: lease lost") - -type TimeCursor struct { - BeforeTime *time.Time - BeforeID *uuid.UUID -} - -type OutboxStore interface { - ClaimOutboxEvent(ctx context.Context, owner string, leaseUntil time.Time) (model.OutboxEvent, error) - CompleteOutboxEvent(ctx context.Context, event model.OutboxEvent) error - RetryOutboxEvent(ctx context.Context, event model.OutboxEvent, nextAttempt time.Time, code, message string) error - CountOutboxBacklog(ctx context.Context) (int64, error) -} - -type JobStore interface { - ClaimJob(ctx context.Context, types []string, owner string, leaseUntil time.Time) (model.Job, error) - RenewJobLease(ctx context.Context, job model.Job, leaseUntil time.Time) error - CompleteJob(ctx context.Context, job model.Job) error - RetryJob(ctx context.Context, job model.Job, runAfter time.Time, code, message string) error - FailJob(ctx context.Context, job model.Job, code, message string) error - ListJobQueueDepths(ctx context.Context, types []string) ([]model.JobQueueDepth, error) -} - -type MetadataStore interface { - JobStore - WithinMetadataTx(ctx context.Context, fn func(MetadataStore) error) error - CreateSCMConnection(ctx context.Context, connection model.SCMConnection) (model.SCMConnection, error) - ListSCMConnections(ctx context.Context, workspaceID uuid.UUID) ([]model.SCMConnection, error) - GetSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) - UpdateSCMConnection(ctx context.Context, connection model.SCMConnection) (model.SCMConnection, error) - SoftDeleteSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) - CreateRepository(ctx context.Context, repository model.Repository) (model.Repository, error) - ListRepositories(ctx context.Context, workspaceID uuid.UUID) ([]model.Repository, error) - GetRepository(ctx context.Context, workspaceID, id uuid.UUID) (model.Repository, error) - SetRepositoryRef(ctx context.Context, workspaceID, id uuid.UUID, ref string) error - ReserveRepositorySync(ctx context.Context, workspaceID, repositoryID uuid.UUID, nextAllowedAt time.Time) error - CreateJob(ctx context.Context, job model.Job, idempotencyKey string) (model.Job, error) - GetJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) - CancelJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) - CancelActiveRepositorySyncJob(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Job, error) - CancelRepositorySyncJobs(ctx context.Context, workspaceID, repositoryID uuid.UUID) error - SoftDeleteRepository(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Repository, error) - MarkRepositorySyncing(ctx context.Context, job model.Job, repositoryID uuid.UUID) error - MarkRepositoryFailed(ctx context.Context, workspaceID, repositoryID uuid.UUID, code, message string) error - MarkSyncReady(ctx context.Context, job model.Job, repositoryID uuid.UUID, commitSHA string) error - MarkSyncFailed(ctx context.Context, job model.Job, repositoryID uuid.UUID, code, message string) error - AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error - AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error - ListAuditLogs(ctx context.Context, workspaceID uuid.UUID, cursor TimeCursor, limit int32) ([]model.AuditLog, error) - GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (model.WorkspaceOverview, error) -} diff --git a/internal/repository/workflow/runner.go b/internal/repository/workflow/runner.go new file mode 100644 index 0000000..323e492 --- /dev/null +++ b/internal/repository/workflow/runner.go @@ -0,0 +1,180 @@ +package workflow + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type Authorizer interface { + Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) +} + +type Credentials interface { + Credential(context.Context, uuid.UUID, uuid.UUID, int64) (identity.ProviderConnection, identity.ProviderCredential, error) +} + +type Runner struct { + store repository.Store + authorizer Authorizer + providers Credentials + git gitrepo.Manager +} + +func NewRunner(store repository.Store, authorizer Authorizer, providers Credentials, git gitrepo.Manager) *Runner { + return &Runner{store: store, authorizer: authorizer, providers: providers, git: git} +} + +func (r *Runner) Execute(ctx context.Context, operationID uuid.UUID) error { + work, err := r.store.GetWorkItem(ctx, operationID) + if err != nil { + return fmt.Errorf("load repository operation: %w", err) + } + if work.Operation.Status == "queued" { + if _, err = r.store.StartOperation(ctx, operationID); err != nil { + return fmt.Errorf("start repository operation: %w", err) + } + } else if work.Operation.Status == "succeeded" { + return r.releaseRedundantSnapshot(ctx, work) + } else if work.Operation.Status != "running" { + return nil + } + if work.Operation.RepositoryVersion != work.Repository.ConfigVersion { + return workflowbiz.Permanent(errors.New("repository configuration changed after operation was requested")) + } + + if work.Operation.Kind == "purge" { + retained, err := r.store.HasRetainedAnalysis(ctx, work.Repository.ID) + if err != nil { + return fmt.Errorf("check retained repository analyses: %w", err) + } + if retained { + return r.store.CompleteRetainedDeletion(ctx, work) + } + if err := r.git.Purge(ctx, work.Repository.ID); err != nil { + return err + } + return r.store.CompletePurge(ctx, work) + } + actor := auth.Actor{UserID: work.Operation.ActorUserID} + if _, err := r.authorizer.Membership(ctx, actor, work.Repository.WorkspaceID, "member"); err != nil { + return workflowbiz.Permanent(errors.New("requesting user no longer has workspace permission")) + } + gitCredential := gitrepo.Credential{} + if work.Operation.ProviderConnectionID != nil { + connection, credential, credentialErr := r.providers.Credential(ctx, work.Operation.ActorUserID, *work.Operation.ProviderConnectionID, work.Operation.CredentialVersion) + if credentialErr != nil { + cause := fmt.Errorf("load current user's provider credential: %w", credentialErr) + problem, isProblem := fault.From(credentialErr) + if errors.Is(credentialErr, pgx.ErrNoRows) || (isProblem && (problem.Code() == "provider.credential_stale" || problem.Code() == "provider.connection_unavailable")) { + return workflowbiz.Permanent(cause) + } + + return cause + } + defer clear(credential.Token) + if connection.ProviderType != work.Operation.RequestedProviderType || connection.Status != "active" { + return workflowbiz.Permanent(errors.New("provider connection is no longer valid for this repository")) + } + gitCredential = gitrepo.Credential{Username: connection.Login, Token: credential.Token} + } + snapshotID := work.Operation.ID + var snapshot gitrepo.Snapshot + if work.Operation.Kind == "provision" { + snapshot, err = r.git.Provision(ctx, work.Repository.ID, work.Operation.RequestedRemoteURL, work.Operation.RequestedRef, snapshotID, gitCredential) + } else { + snapshot, err = r.git.Sync(ctx, work.Repository.ID, work.Operation.RequestedRemoteURL, work.Operation.RequestedRef, snapshotID, gitCredential) + } + if err != nil { + if gitrepo.IsPermanent(err) { + return workflowbiz.Permanent(err) + } + + return err + } + return r.completeSync(ctx, work, snapshotID, snapshot) +} + +func (r *Runner) completeSync(ctx context.Context, work repository.WorkItem, snapshotID uuid.UUID, snapshot gitrepo.Snapshot) error { + operation, err := r.store.CompleteSync(ctx, work, snapshotID, snapshot) + if err != nil { + return r.reconcileSnapshot(work, snapshotID, snapshot, err) + } + + work.Operation = operation + + return r.releaseRedundantSnapshot(ctx, work) +} + +func (r *Runner) releaseRedundantSnapshot(ctx context.Context, work repository.WorkItem) error { + operation := work.Operation + if operation.SnapshotID == nil || *operation.SnapshotID == operation.ID { + return nil + } + if operation.ResolvedCommitSHA == "" { + return workflowbiz.Permanent(errors.New("completed repository operation has no resolved commit")) + } + if err := r.git.ReleaseSnapshot(ctx, work.Repository.ID, operation.ID, operation.ResolvedCommitSHA); err != nil { + return fmt.Errorf("release redundant snapshot pin: %w", err) + } + + return nil +} + +func (r *Runner) reconcileSnapshot(work repository.WorkItem, snapshotID uuid.UUID, snapshot gitrepo.Snapshot, completionErr error) error { + reconcileContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + persisted, err := r.store.Snapshot(reconcileContext, work.Repository.WorkspaceID, work.Repository.ID, snapshotID) + if err == nil { + if !strings.EqualFold(persisted.CommitSHA, snapshot.CommitSHA) { + return workflowbiz.Permanent(errors.New("persisted snapshot commit does not match the Git pin")) + } + + return nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return completionErr + } + if err := r.git.ReleaseSnapshot(reconcileContext, work.Repository.ID, snapshotID, snapshot.CommitSHA); err != nil { + return errors.Join(completionErr, fmt.Errorf("release uncommitted snapshot pin: %w", err)) + } + + return completionErr +} + +func (r *Runner) MarkFailed(ctx context.Context, operationID uuid.UUID, cause error) error { + work, err := r.store.GetWorkItem(ctx, operationID) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + failure := repository.Failure{Code: "repository.sync_failed", Message: "Repository synchronization failed"} + if work.Operation.Kind == "purge" { + failure = repository.Failure{Code: "repository.purge_failed", Message: "Repository deletion failed"} + } + if errors.Is(cause, gitrepo.ErrMirrorQuota) { + failure = repository.Failure{Code: "repository.mirror_quota_exceeded", Message: "Repository mirror exceeds the configured storage limit"} + } + if errors.Is(cause, gitrepo.ErrRemoteBlocked) { + failure = repository.Failure{Code: "repository.remote_blocked", Message: "Repository remote is blocked by network policy"} + } + if errors.Is(cause, gitrepo.ErrAuthentication) { + failure = repository.Failure{Code: "repository.authentication_failed", Message: "Repository authentication failed; add or update a personal access token"} + } + + return r.store.FailOperation(ctx, work, failure) +} diff --git a/internal/repository/workflow/runner_test.go b/internal/repository/workflow/runner_test.go new file mode 100644 index 0000000..ac39bd2 --- /dev/null +++ b/internal/repository/workflow/runner_test.go @@ -0,0 +1,532 @@ +package workflow + +import ( + "context" + "errors" + "testing" + + identity "github.com/fuchencong/mooncode/internal/identity/biz" + "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/platform/fault" + repository "github.com/fuchencong/mooncode/internal/repository/biz" + workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type repositoryWorkflowStore struct { + repository.Store + work repository.WorkItem + started bool + completed bool + purged bool + retained bool + logicallyDeleted bool + failure repository.Failure + completedSnapshot gitrepo.Snapshot + completeOperation repository.Operation + snapshotID uuid.UUID + completeErr error + persistedSnapshot repository.Snapshot + snapshotLookupErr error +} + +func (s *repositoryWorkflowStore) GetWorkItem(context.Context, uuid.UUID) (repository.WorkItem, error) { + return s.work, nil +} + +func (s *repositoryWorkflowStore) StartOperation(context.Context, uuid.UUID) (repository.Operation, error) { + s.started = true + s.work.Operation.Status = "running" + + return s.work.Operation, nil +} + +func (s *repositoryWorkflowStore) Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (repository.Snapshot, error) { + return s.persistedSnapshot, s.snapshotLookupErr +} + +func (s *repositoryWorkflowStore) CompleteSync(_ context.Context, _ repository.WorkItem, snapshotID uuid.UUID, snapshot gitrepo.Snapshot) (repository.Operation, error) { + s.completed = true + s.snapshotID = snapshotID + s.completedSnapshot = snapshot + + if s.completeOperation.ID != uuid.Nil { + return s.completeOperation, s.completeErr + } + + return s.work.Operation, s.completeErr +} + +func (s *repositoryWorkflowStore) CompletePurge(context.Context, repository.WorkItem) error { + s.purged = true + + return nil +} + +func (s *repositoryWorkflowStore) HasRetainedAnalysis(context.Context, uuid.UUID) (bool, error) { + return s.retained, nil +} + +func (s *repositoryWorkflowStore) CompleteRetainedDeletion(context.Context, repository.WorkItem) error { + s.logicallyDeleted = true + + return nil +} + +func (s *repositoryWorkflowStore) FailOperation(_ context.Context, _ repository.WorkItem, failure repository.Failure) error { + s.failure = failure + + return nil +} + +type repositoryWorkflowAuthorizer struct { + actor auth.Actor + err error +} + +func (a *repositoryWorkflowAuthorizer) Membership(_ context.Context, actor auth.Actor, _ uuid.UUID, _ string) (identity.Membership, error) { + a.actor = actor + + return identity.Membership{Role: "member"}, a.err +} + +type repositoryWorkflowCredentials struct { + connection identity.ProviderConnection + credential identity.ProviderCredential + err error + userID uuid.UUID + version int64 + calls int +} + +func (p *repositoryWorkflowCredentials) Credential(_ context.Context, userID, _ uuid.UUID, version int64) (identity.ProviderConnection, identity.ProviderCredential, error) { + p.calls++ + p.userID = userID + p.version = version + + return p.connection, p.credential, p.err +} + +type repositoryWorkflowGit struct { + provisioned bool + synced bool + purged bool + repositoryID uuid.UUID + remoteURL string + ref string + snapshotID uuid.UUID + credential gitrepo.Credential + credentialToken string + snapshot gitrepo.Snapshot + released bool + releasedSnapshotID uuid.UUID + releasedCommit string + releaseErr error +} + +func (g *repositoryWorkflowGit) Provision(_ context.Context, id uuid.UUID, remoteURL, ref string, snapshotID uuid.UUID, credential gitrepo.Credential) (gitrepo.Snapshot, error) { + g.provisioned = true + g.capture(id, remoteURL, ref, snapshotID, credential) + + return g.snapshot, nil +} + +func (g *repositoryWorkflowGit) Sync(_ context.Context, id uuid.UUID, remoteURL, ref string, snapshotID uuid.UUID, credential gitrepo.Credential) (gitrepo.Snapshot, error) { + g.synced = true + g.capture(id, remoteURL, ref, snapshotID, credential) + + return g.snapshot, nil +} + +func (*repositoryWorkflowGit) Checkout(context.Context, uuid.UUID, string) (string, func() error, error) { + return "", func() error { return nil }, nil +} + +func (g *repositoryWorkflowGit) Purge(_ context.Context, id uuid.UUID) error { + g.purged = true + g.repositoryID = id + + return nil +} + +func (g *repositoryWorkflowGit) ReleaseSnapshot(_ context.Context, _ uuid.UUID, snapshotID uuid.UUID, commitSHA string) error { + g.released = true + g.releasedSnapshotID = snapshotID + g.releasedCommit = commitSHA + + return g.releaseErr +} + +func (*repositoryWorkflowGit) Path(uuid.UUID) string { return "" } + +func (g *repositoryWorkflowGit) capture(id uuid.UUID, remoteURL, ref string, snapshotID uuid.UUID, credential gitrepo.Credential) { + g.repositoryID = id + g.remoteURL = remoteURL + g.ref = ref + g.snapshotID = snapshotID + g.credential = credential + g.credentialToken = string(credential.Token) +} + +func TestRunnerSkipsTerminalOperation(t *testing.T) { + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New()}, + Operation: repository.Operation{ID: uuid.New(), Status: "succeeded"}, + }} + git := &repositoryWorkflowGit{} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, git) + + if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil { + t.Fatal(err) + } + if store.started || store.completed || store.purged || git.provisioned || git.synced || git.purged { + t.Fatal("terminal repository operation was executed again") + } +} + +func TestRunnerProvisionUsesFrozenActorCredential(t *testing.T) { + actorID, workspaceID, repositoryID, connectionID := uuid.New(), uuid.New(), uuid.New(), uuid.New() + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, ProviderType: "github", RemoteURL: "https://github.com/example/repo.git"}, + Operation: repository.Operation{ + ID: uuid.New(), ActorUserID: actorID, ProviderConnectionID: &connectionID, + CredentialVersion: 7, Kind: "provision", RequestedProviderType: "github", + RequestedRemoteURL: "https://github.com/example/repo.git", RequestedRef: "main", Status: "queued", + }, + }} + authorizer := &repositoryWorkflowAuthorizer{} + providers := &repositoryWorkflowCredentials{ + connection: identity.ProviderConnection{ID: connectionID, ProviderType: "github", Login: "octocat", Status: "active"}, + credential: identity.ProviderCredential{Token: []byte("github_pat_secret")}, + } + git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: "1234567890abcdef"}} + runner := NewRunner(store, authorizer, providers, git) + + if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil { + t.Fatal(err) + } + if !store.started || !store.completed || !git.provisioned || git.synced { + t.Fatalf("unexpected workflow state: store=%+v git=%+v", store, git) + } + if authorizer.actor.UserID != actorID || providers.userID != actorID || providers.version != 7 || providers.calls != 1 { + t.Fatalf("workflow did not use frozen actor credential: actor=%s provider=%+v", authorizer.actor.UserID, providers) + } + if git.credential.Username != "octocat" || git.credentialToken != "github_pat_secret" || git.ref != "main" || git.remoteURL != store.work.Operation.RequestedRemoteURL { + t.Fatalf("unexpected Git request: %+v", git) + } + if !allZero(providers.credential.Token) { + t.Fatal("provider credential plaintext was not cleared after the Git operation") + } + if store.snapshotID != store.work.Operation.ID || store.snapshotID != git.snapshotID || store.completedSnapshot.CommitSHA != git.snapshot.CommitSHA { + t.Fatalf("snapshot was not completed consistently: store=%+v git=%+v", store, git) + } +} + +func TestRunnerProvisionPublicRepositoryWithoutCredential(t *testing.T) { + workspaceID, repositoryID := uuid.New(), uuid.New() + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, ProviderType: "github", RemoteURL: "https://github.com/example/public-repo.git"}, + Operation: repository.Operation{ + ID: uuid.New(), ActorUserID: uuid.New(), Kind: "provision", RequestedProviderType: "github", + RequestedRemoteURL: "https://github.com/example/public-repo.git", RequestedRef: "main", Status: "queued", + }, + }} + providers := &repositoryWorkflowCredentials{} + git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: "1234567890abcdef"}} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git) + + if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil { + t.Fatal(err) + } + if providers.calls != 0 || !git.provisioned || len(git.credential.Token) != 0 || git.credential.Username != "" { + t.Fatalf("public synchronization used a credential: provider=%+v git=%+v", providers, git) + } +} + +func TestRunnerReleasesRedundantSnapshotPin(t *testing.T) { + operationID := uuid.New() + existingSnapshotID := uuid.New() + commitSHA := "1234567890abcdef1234567890abcdef12345678" + completed := repository.Operation{ + ID: operationID, Status: "succeeded", SnapshotID: &existingSnapshotID, + ResolvedCommitSHA: commitSHA, + } + store := &repositoryWorkflowStore{ + completeOperation: completed, + work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()}, + Operation: repository.Operation{ + ID: operationID, ActorUserID: uuid.New(), Kind: "refresh", + RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/public-repo.git", + RequestedRef: "main", Status: "queued", + }, + }, + } + git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: commitSHA}} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, git) + + if err := runner.Execute(context.Background(), operationID); err != nil { + t.Fatal(err) + } + if !store.completed || !git.released || git.releasedSnapshotID != operationID || git.releasedCommit != commitSHA { + t.Fatalf("redundant snapshot pin was not released: store=%+v git=%+v", store, git) + } +} + +func TestRunnerRetriesRedundantSnapshotCleanupForSucceededOperation(t *testing.T) { + operationID := uuid.New() + existingSnapshotID := uuid.New() + commitSHA := "1234567890abcdef1234567890abcdef12345678" + cleanupErr := errors.New("release snapshot pin") + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New()}, + Operation: repository.Operation{ + ID: operationID, Status: "succeeded", SnapshotID: &existingSnapshotID, + ResolvedCommitSHA: commitSHA, + }, + }} + git := &repositoryWorkflowGit{releaseErr: cleanupErr} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, git) + + if err := runner.Execute(context.Background(), operationID); !errors.Is(err, cleanupErr) { + t.Fatalf("Execute() error = %v, want cleanup error", err) + } + git.releaseErr = nil + if err := runner.Execute(context.Background(), operationID); err != nil { + t.Fatal(err) + } + if store.started || store.completed || git.provisioned || git.synced || git.releasedSnapshotID != operationID { + t.Fatalf("terminal cleanup reran repository synchronization: store=%+v git=%+v", store, git) + } +} + +func TestRunnerReleasesSnapshotPinWhenDatabaseCompletionDidNotCommit(t *testing.T) { + connectionID := uuid.New() + completionErr := errors.New("complete repository operation") + store := &repositoryWorkflowStore{ + completeErr: completionErr, + snapshotLookupErr: pgx.ErrNoRows, + work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1}, + Operation: repository.Operation{ + ID: uuid.New(), ActorUserID: uuid.New(), ProviderConnectionID: &connectionID, + CredentialVersion: 1, RepositoryVersion: 1, Kind: "refresh", + RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/repo.git", + RequestedRef: "main", Status: "running", + }, + }, + } + providers := &repositoryWorkflowCredentials{ + connection: identity.ProviderConnection{ID: connectionID, ProviderType: "github", Login: "octocat", Status: "active"}, + credential: identity.ProviderCredential{Token: []byte("token")}, + } + git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: "1234567890abcdef1234567890abcdef12345678"}} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git) + + err := runner.Execute(context.Background(), store.work.Operation.ID) + if !errors.Is(err, completionErr) { + t.Fatalf("Execute() error = %v, want completion error", err) + } + if !git.released || git.releasedCommit != git.snapshot.CommitSHA { + t.Fatalf("uncommitted snapshot pin was not released: %+v", git) + } +} + +func TestRunnerAcceptsCommittedSnapshotAfterAmbiguousCompletionError(t *testing.T) { + connectionID := uuid.New() + commitSHA := "1234567890abcdef1234567890abcdef12345678" + store := &repositoryWorkflowStore{ + completeErr: errors.New("commit result was lost"), + persistedSnapshot: repository.Snapshot{CommitSHA: commitSHA}, + work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1}, + Operation: repository.Operation{ + ID: uuid.New(), ActorUserID: uuid.New(), ProviderConnectionID: &connectionID, + CredentialVersion: 1, RepositoryVersion: 1, Kind: "refresh", + RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/repo.git", + RequestedRef: "main", Status: "running", + }, + }, + } + providers := &repositoryWorkflowCredentials{ + connection: identity.ProviderConnection{ID: connectionID, ProviderType: "github", Login: "octocat", Status: "active"}, + credential: identity.ProviderCredential{Token: []byte("token")}, + } + git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: commitSHA}} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git) + + if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil { + t.Fatal(err) + } + if git.released { + t.Fatal("committed snapshot pin was released") + } +} + +func allZero(value []byte) bool { + for _, item := range value { + if item != 0 { + return false + } + } + + return true +} + +func TestRunnerPurgeIsLocalAndDoesNotLoadProviderCredential(t *testing.T) { + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()}, + Operation: repository.Operation{ID: uuid.New(), ActorUserID: uuid.New(), Kind: "purge", Status: "queued"}, + }} + providers := &repositoryWorkflowCredentials{} + git := &repositoryWorkflowGit{} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git) + + if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil { + t.Fatal(err) + } + if !git.purged || !store.purged || providers.calls != 0 { + t.Fatalf("purge accessed remote credential or did not complete: git=%+v store=%+v providers=%+v", git, store, providers) + } +} + +func TestRunnerRetainsMirrorReferencedByAnalysis(t *testing.T) { + store := &repositoryWorkflowStore{retained: true, work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()}, + Operation: repository.Operation{ID: uuid.New(), ActorUserID: uuid.New(), Kind: "purge", Status: "queued"}, + }} + providers := &repositoryWorkflowCredentials{} + git := &repositoryWorkflowGit{} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git) + + if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil { + t.Fatal(err) + } + if git.purged || store.purged || !store.logicallyDeleted || providers.calls != 0 { + t.Fatalf("retained repository deletion was unsafe: git=%+v store=%+v", git, store) + } +} + +func TestRunnerRejectsActorWithoutCurrentWorkspaceAccess(t *testing.T) { + connectionID := uuid.New() + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()}, + Operation: repository.Operation{ + ID: uuid.New(), ActorUserID: uuid.New(), ProviderConnectionID: &connectionID, + Kind: "refresh", Status: "queued", + }, + }} + git := &repositoryWorkflowGit{} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{err: errors.New("forbidden")}, &repositoryWorkflowCredentials{}, git) + + if err := runner.Execute(context.Background(), store.work.Operation.ID); err == nil || !workflowbiz.IsPermanent(err) { + t.Fatal("expected removed member to be rejected") + } + if git.purged || store.purged { + t.Fatal("Git mutation ran without current workspace permission") + } +} + +func TestRunnerDoesNotRetryStaleProviderCredential(t *testing.T) { + connectionID := uuid.New() + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()}, + Operation: repository.Operation{ + ID: uuid.New(), ActorUserID: uuid.New(), ProviderConnectionID: &connectionID, + CredentialVersion: 2, Kind: "refresh", Status: "queued", + }, + }} + providers := &repositoryWorkflowCredentials{err: fault.New(fault.Conflict, "provider.credential_stale", "Provider credential changed")} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, &repositoryWorkflowGit{}) + + err := runner.Execute(context.Background(), store.work.Operation.ID) + if err == nil || !workflowbiz.IsPermanent(err) { + t.Fatalf("stale provider credential error = %v, want permanent failure", err) + } +} + +func TestRunnerPurgeDoesNotDependOnFormerMember(t *testing.T) { + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()}, + Operation: repository.Operation{ID: uuid.New(), ActorUserID: uuid.New(), Kind: "purge", Status: "queued"}, + }} + git := &repositoryWorkflowGit{} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{err: errors.New("former member")}, &repositoryWorkflowCredentials{}, git) + + if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil { + t.Fatal(err) + } + if !git.purged || !store.purged { + t.Fatal("authorized local purge was blocked by later membership change") + } +} + +func TestRunnerRejectsStaleRepositoryConfiguration(t *testing.T) { + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 3}, + Operation: repository.Operation{ID: uuid.New(), ActorUserID: uuid.New(), RepositoryVersion: 2, Kind: "refresh", Status: "queued"}, + }} + providers := &repositoryWorkflowCredentials{} + git := &repositoryWorkflowGit{} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git) + + err := runner.Execute(context.Background(), store.work.Operation.ID) + if err == nil || err.Error() != "repository configuration changed after operation was requested" { + t.Fatalf("Execute() error = %v, want stale configuration failure", err) + } + if !workflowbiz.IsPermanent(err) { + t.Fatal("stale repository operation was not classified as permanent") + } + if providers.calls != 0 || git.provisioned || git.synced || git.purged || store.completed { + t.Fatal("stale repository operation performed side effects") + } +} + +func TestRunnerProjectsMirrorQuotaFailure(t *testing.T) { + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1}, + Operation: repository.Operation{ID: uuid.New(), RepositoryVersion: 1, Kind: "refresh", Status: "running"}, + }} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, &repositoryWorkflowGit{}) + + err := runner.MarkFailed(context.Background(), store.work.Operation.ID, &gitrepo.MirrorQuotaError{LimitBytes: 10, ActualBytes: 11}) + if err != nil { + t.Fatal(err) + } + if store.failure.Code != "repository.mirror_quota_exceeded" || store.failure.Message != "Repository mirror exceeds the configured storage limit" { + t.Fatalf("unexpected quota projection: %+v", store.failure) + } +} + +func TestRunnerProjectsBlockedRemoteFailure(t *testing.T) { + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1}, + Operation: repository.Operation{ID: uuid.New(), RepositoryVersion: 1, Kind: "provision", Status: "running"}, + }} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, &repositoryWorkflowGit{}) + + err := runner.MarkFailed(context.Background(), store.work.Operation.ID, gitrepo.ErrRemoteBlocked) + if err != nil { + t.Fatal(err) + } + if store.failure.Code != "repository.remote_blocked" || store.failure.Message != "Repository remote is blocked by network policy" { + t.Fatalf("unexpected blocked remote projection: %+v", store.failure) + } +} + +func TestRunnerProjectsAuthenticationFailure(t *testing.T) { + store := &repositoryWorkflowStore{work: repository.WorkItem{ + Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1}, + Operation: repository.Operation{ID: uuid.New(), RepositoryVersion: 1, Kind: "refresh", Status: "running"}, + }} + runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, &repositoryWorkflowGit{}) + + if err := runner.MarkFailed(context.Background(), store.work.Operation.ID, gitrepo.ErrAuthentication); err != nil { + t.Fatal(err) + } + if store.failure.Code != "repository.authentication_failed" || store.failure.Message != "Repository authentication failed; add or update a personal access token" { + t.Fatalf("authentication failure projection = %+v", store.failure) + } +} diff --git a/internal/retention/biz/model.go b/internal/retention/biz/model.go new file mode 100644 index 0000000..3c8a4be --- /dev/null +++ b/internal/retention/biz/model.go @@ -0,0 +1,26 @@ +package biz + +import ( + "time" + + "github.com/google/uuid" +) + +type Cleanup struct { + ID uuid.UUID + WorkspaceID uuid.UUID + ScheduledFor time.Time + RetentionDays int32 + Status string + WorkflowRunID string + DeletedRunCount int32 + PurgedSnapshotCount int32 + RequeuedRepositoryCount int32 + ErrorMessage string +} + +type SnapshotPin struct { + ID uuid.UUID + RepositoryID uuid.UUID + CommitSHA string +} diff --git a/internal/retention/biz/store.go b/internal/retention/biz/store.go new file mode 100644 index 0000000..6a6e756 --- /dev/null +++ b/internal/retention/biz/store.go @@ -0,0 +1,19 @@ +package biz + +import ( + "context" + + "github.com/google/uuid" +) + +type Store interface { + ScheduleDue(context.Context, int32) (int, error) + GetCleanup(context.Context, uuid.UUID) (Cleanup, error) + StartCleanup(context.Context, uuid.UUID) (Cleanup, error) + DeleteExpiredRuns(context.Context, Cleanup, int32) (int, error) + ClaimSnapshotPins(context.Context, Cleanup, int32) ([]SnapshotPin, error) + CompleteSnapshotPurge(context.Context, Cleanup, SnapshotPin) error + RequeueRepositoryPurges(context.Context, Cleanup, int32) (int, error) + FinishCleanup(context.Context, uuid.UUID) error + FailCleanup(context.Context, uuid.UUID, string) error +} diff --git a/internal/retention/data/store.go b/internal/retention/data/store.go new file mode 100644 index 0000000..3672ab0 --- /dev/null +++ b/internal/retention/data/store.go @@ -0,0 +1,294 @@ +package data + +import ( + "context" + "errors" + + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/platform/audit" + retention "github.com/fuchencong/mooncode/internal/retention/biz" + workflow "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool + queries *sqlc.Queries +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool, queries: sqlc.New(pool)} +} + +func (s *Store) ScheduleDue(ctx context.Context, limit int32) (int, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback(ctx) }() + + q := s.queries.WithTx(tx) + schedules, err := q.ClaimDueRetentionSchedules(ctx, limit) + if err != nil { + return 0, err + } + for _, schedule := range schedules { + cleanupID := uuid.New() + if _, err = q.CreateRetentionCleanup(ctx, sqlc.CreateRetentionCleanupParams{ + ID: cleanupID, WorkspaceID: schedule.WorkspaceID, + ScheduledFor: schedule.NextRunAt, RetentionDays: schedule.ReportRetentionDays, + }); err != nil { + return 0, err + } + + dispatch, dispatchErr := workflow.NewDispatch( + workflow.AggregateRetentionCleanup, + cleanupID, + workflow.WorkflowRetentionCleanup, + workflow.Payload{AggregateID: cleanupID, WorkspaceID: schedule.WorkspaceID}, + ) + if dispatchErr != nil { + return 0, dispatchErr + } + if _, err = q.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{ + ID: dispatch.ID, AggregateType: dispatch.AggregateType, AggregateID: dispatch.AggregateID, + WorkflowName: dispatch.WorkflowName, Payload: dispatch.Payload, + }); err != nil { + return 0, err + } + + advanced, advanceErr := q.AdvanceRetentionSchedule(ctx, sqlc.AdvanceRetentionScheduleParams{ + WorkspaceID: schedule.WorkspaceID, NextRunAt: schedule.NextRunAt, + }) + if advanceErr != nil { + return 0, advanceErr + } + if advanced != 1 { + return 0, errors.New("retention schedule changed while locked") + } + } + + if err = tx.Commit(ctx); err != nil { + return 0, err + } + + return len(schedules), nil +} + +func (s *Store) GetCleanup(ctx context.Context, id uuid.UUID) (retention.Cleanup, error) { + row, err := s.queries.GetRetentionCleanup(ctx, id) + + return mapCleanup(row), err +} + +func (s *Store) StartCleanup(ctx context.Context, id uuid.UUID) (retention.Cleanup, error) { + row, err := s.queries.StartRetentionCleanup(ctx, id) + + return mapCleanup(row), err +} + +func (s *Store) DeleteExpiredRuns(ctx context.Context, cleanup retention.Cleanup, limit int32) (int, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback(ctx) }() + + q := s.queries.WithTx(tx) + snapshotIDs, err := q.DeleteExpiredAnalysisRuns(ctx, sqlc.DeleteExpiredAnalysisRunsParams{ + WorkspaceID: cleanup.WorkspaceID, RetentionDays: cleanup.RetentionDays, Limit: limit, + }) + if err != nil { + return 0, err + } + if len(snapshotIDs) > 0 { + updated, updateErr := q.AddRetentionCleanupProgress(ctx, sqlc.AddRetentionCleanupProgressParams{ + ID: cleanup.ID, DeletedRuns: int32(len(snapshotIDs)), + }) + if updateErr != nil { + return 0, updateErr + } + if updated != 1 { + return 0, errors.New("retention cleanup is not running") + } + } + + if err = tx.Commit(ctx); err != nil { + return 0, err + } + + return len(snapshotIDs), nil +} + +func (s *Store) ClaimSnapshotPins(ctx context.Context, cleanup retention.Cleanup, limit int32) ([]retention.SnapshotPin, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + + q := s.queries.WithTx(tx) + cleanupID := uuid.NullUUID{UUID: cleanup.ID, Valid: true} + candidates, err := q.LockPurgeableSnapshotCandidates(ctx, sqlc.LockPurgeableSnapshotCandidatesParams{ + WorkspaceID: cleanup.WorkspaceID, CleanupID: cleanupID, Limit: limit, + }) + if err != nil { + return nil, err + } + if len(candidates) == 0 { + if err = tx.Commit(ctx); err != nil { + return nil, err + } + + return []retention.SnapshotPin{}, nil + } + rows, err := q.MarkSnapshotsPurging(ctx, sqlc.MarkSnapshotsPurgingParams{ + CleanupID: cleanupID, SnapshotIds: candidates, WorkspaceID: cleanup.WorkspaceID, + }) + if err != nil { + return nil, err + } + + items := make([]retention.SnapshotPin, 0, len(rows)) + for _, row := range rows { + items = append(items, retention.SnapshotPin{ID: row.ID, RepositoryID: row.RepositoryID, CommitSHA: row.CommitSha}) + } + if err = tx.Commit(ctx); err != nil { + return nil, err + } + + return items, nil +} + +func (s *Store) CompleteSnapshotPurge(ctx context.Context, cleanup retention.Cleanup, pin retention.SnapshotPin) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + + q := s.queries.WithTx(tx) + updated, err := q.CompleteSnapshotPurge(ctx, sqlc.CompleteSnapshotPurgeParams{ + ID: pin.ID, RepositoryID: pin.RepositoryID, + PurgeCleanupID: uuid.NullUUID{UUID: cleanup.ID, Valid: true}, + }) + if err != nil { + return err + } + if updated == 0 { + return nil + } + if _, err = q.AddRetentionCleanupProgress(ctx, sqlc.AddRetentionCleanupProgressParams{ + ID: cleanup.ID, PurgedSnapshots: 1, + }); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (s *Store) RequeueRepositoryPurges(ctx context.Context, cleanup retention.Cleanup, limit int32) (int, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback(ctx) }() + + q := s.queries.WithTx(tx) + repositories, err := q.ClaimRepositoriesReadyForPurge(ctx, sqlc.ClaimRepositoriesReadyForPurgeParams{ + WorkspaceID: cleanup.WorkspaceID, Limit: limit, + }) + if err != nil { + return 0, err + } + for _, repository := range repositories { + updated, updateErr := q.ReactivateRepositoryPurge(ctx, repository.ID) + if updateErr != nil { + return 0, updateErr + } + if updated != 1 { + return 0, errors.New("deleted repository changed while locked") + } + + operationID := uuid.New() + if _, err = q.CreateRepositoryOperation(ctx, sqlc.CreateRepositoryOperationParams{ + ID: operationID, RepositoryID: repository.ID, ActorUserID: repository.CreatedBy, + RepositoryVersion: repository.ConfigVersion, Kind: "purge", + RequestedProviderType: repository.ProviderType, RequestedRemoteUrl: repository.RemoteUrl, + RequestedNormalizedUrl: repository.NormalizedUrl, RequestedRef: repository.ConfiguredRef, + PreviousProviderType: repository.ProviderType, PreviousRemoteUrl: repository.RemoteUrl, + PreviousNormalizedUrl: repository.NormalizedUrl, PreviousRef: repository.ConfiguredRef, + }); err != nil { + return 0, err + } + dispatch, dispatchErr := workflow.NewDispatch( + workflow.AggregateRepositoryOperation, + operationID, + workflow.WorkflowRepositoryOperation, + workflow.Payload{AggregateID: operationID, RepositoryID: repository.ID}, + ) + if dispatchErr != nil { + return 0, dispatchErr + } + if _, err = q.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{ + ID: dispatch.ID, AggregateType: dispatch.AggregateType, AggregateID: dispatch.AggregateID, + WorkflowName: dispatch.WorkflowName, Payload: dispatch.Payload, + }); err != nil { + return 0, err + } + if err = audit.Record(ctx, q, audit.Event{ + WorkspaceID: cleanup.WorkspaceID, + Action: audit.ActionRepositoryPurgeRequeued, + Resource: audit.ResourceRepository, + ResourceID: repository.ID, + Metadata: map[string]any{"cleanupId": cleanup.ID, "operationId": operationID}, + }); err != nil { + return 0, err + } + } + if len(repositories) > 0 { + updated, updateErr := q.AddRequeuedRepositoryCount(ctx, sqlc.AddRequeuedRepositoryCountParams{ + ID: cleanup.ID, RequeuedRepositoryCount: int32(len(repositories)), + }) + if updateErr != nil { + return 0, updateErr + } + if updated != 1 { + return 0, errors.New("retention cleanup is not running") + } + } + + if err = tx.Commit(ctx); err != nil { + return 0, err + } + + return len(repositories), nil +} + +func (s *Store) FinishCleanup(ctx context.Context, id uuid.UUID) error { + _, err := s.queries.FinishRetentionCleanup(ctx, id) + + return err +} + +func (s *Store) FailCleanup(ctx context.Context, id uuid.UUID, message string) error { + if len(message) > 1000 { + message = message[:1000] + } + _, err := s.queries.FailRetentionCleanup(ctx, sqlc.FailRetentionCleanupParams{ + ID: id, ErrorMessage: pgtype.Text{String: message, Valid: message != ""}, + }) + + return err +} + +func mapCleanup(row sqlc.RetentionCleanup) retention.Cleanup { + return retention.Cleanup{ + ID: row.ID, WorkspaceID: row.WorkspaceID, ScheduledFor: row.ScheduledFor.Time, + RetentionDays: row.RetentionDays, Status: row.Status, WorkflowRunID: row.WorkflowRunID.String, + DeletedRunCount: row.DeletedRunCount, PurgedSnapshotCount: row.PurgedSnapshotCount, + RequeuedRepositoryCount: row.RequeuedRepositoryCount, ErrorMessage: row.ErrorMessage.String, + } +} diff --git a/internal/retention/data/store_integration_test.go b/internal/retention/data/store_integration_test.go new file mode 100644 index 0000000..a876b46 --- /dev/null +++ b/internal/retention/data/store_integration_test.go @@ -0,0 +1,199 @@ +//go:build integration + +package data + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "strings" + "testing" + "time" + + analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + analysisdata "github.com/fuchencong/mooncode/internal/analysis/data" + "github.com/fuchencong/mooncode/internal/platform/fault" + retention "github.com/fuchencong/mooncode/internal/retention/biz" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestRetentionCleanupReleasesReferencesAndRequeuesRepositoryPurge(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + userID, workspaceID, profileID := uuid.New(), uuid.New(), uuid.New() + repositoryID, deletedRepositoryID := uuid.New(), uuid.New() + expiredSnapshotID, sharedSnapshotID, currentSnapshotID, deletedCurrentSnapshotID := uuid.New(), uuid.New(), uuid.New(), uuid.New() + cleanupID, channelID := uuid.New(), uuid.New() + profileDefinition := analysis.DefaultProfileDefinition() + statements := []struct { + query string + args []any + }{ + {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{userID}}, + {`INSERT INTO workspaces (id,name,slug,report_retention_days,created_by) VALUES ($1,'Retention',$2,30,$3)`, []any{workspaceID, "retention-" + workspaceID.String(), userID}}, + {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, userID}}, + {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, userID}}, + {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','active','https://github.com/example/active.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/active-" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", userID}}, + {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,deleted_at,created_by) VALUES ($1,$2,'github','deleted','https://github.com/example/deleted.git',$3,'main',$4,'deleted',clock_timestamp(),$5)`, []any{deletedRepositoryID, workspaceID, "github.com/example/deleted-" + deletedRepositoryID.String(), "/tmp/" + deletedRepositoryID.String() + ".git", userID}}, + {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{expiredSnapshotID, repositoryID, strings.Repeat("a", 40), "refs/mooncode/snapshots/" + expiredSnapshotID.String()}}, + {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{sharedSnapshotID, repositoryID, strings.Repeat("b", 40), "refs/mooncode/snapshots/" + sharedSnapshotID.String()}}, + {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{currentSnapshotID, repositoryID, strings.Repeat("c", 40), "refs/mooncode/snapshots/" + currentSnapshotID.String()}}, + {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{deletedCurrentSnapshotID, deletedRepositoryID, strings.Repeat("d", 40), "refs/mooncode/snapshots/" + deletedCurrentSnapshotID.String()}}, + {`UPDATE repositories SET current_snapshot_id=$2 WHERE id=$1`, []any{repositoryID, currentSnapshotID}}, + {`UPDATE repositories SET current_snapshot_id=$2 WHERE id=$1`, []any{deletedRepositoryID, deletedCurrentSnapshotID}}, + {`INSERT INTO channels (id,workspace_id,type,name) VALUES ($1,$2,'feishu','Retention notifications')`, []any{channelID, workspaceID}}, + {`INSERT INTO retention_cleanups (id,workspace_id,scheduled_for,retention_days,status,started_at) VALUES ($1,$2,clock_timestamp(),30,'running',clock_timestamp())`, []any{cleanupID, workspaceID}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatal(err) + } + } + + insertRun := func(repositoryID, snapshotID uuid.UUID, status string, finishedAt *time.Time) uuid.UUID { + t.Helper() + runID := uuid.New() + digest := sha256.Sum256(runID[:]) + stage := "queued" + var startedAt any + var finished any + if finishedAt != nil { + stage = "complete" + startedAt = finishedAt.Add(-time.Minute) + finished = *finishedAt + } + _, err := pool.Exec(ctx, ` +INSERT INTO analysis_runs ( + id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key, + profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt, + status,stage,started_at,finished_at +) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,$10,$11,$12,$13)`, + runID, workspaceID, repositoryID, snapshotID, snapshotCommit(snapshotID, map[uuid.UUID]string{ + expiredSnapshotID: strings.Repeat("a", 40), sharedSnapshotID: strings.Repeat("b", 40), + currentSnapshotID: strings.Repeat("c", 40), deletedCurrentSnapshotID: strings.Repeat("d", 40), + }), userID, profileID, profileDefinition, hex.EncodeToString(digest[:]), status, stage, startedAt, finished) + if err != nil { + t.Fatal(err) + } + + return runID + } + + expiredAt := time.Now().Add(-31 * 24 * time.Hour) + recentAt := time.Now().Add(-24 * time.Hour) + expiredRunID := insertRun(repositoryID, expiredSnapshotID, "succeeded", &expiredAt) + deletedRepositoryRunID := insertRun(deletedRepositoryID, deletedCurrentSnapshotID, "succeeded", &expiredAt) + recentRunID := insertRun(repositoryID, sharedSnapshotID, "succeeded", &recentAt) + queuedRunID := insertRun(repositoryID, sharedSnapshotID, "queued", nil) + reportID, notificationID, dispatchID, auditID := uuid.New(), uuid.New(), uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, ` +INSERT INTO analysis_reports ( + id,analysis_run_id,workspace_id,repository_id,snapshot_id,commit_sha,source_ref, + dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version, + execution_environment,started_at,finished_at,duration_ms,result,raw_artifact +) VALUES ($1,$2,$3,$4,$5,$6,'main','code_scale',$7,'v1',$8,'test','integration', + $9,$10,60000,'{}','{}')`, reportID, expiredRunID, workspaceID, repositoryID, + expiredSnapshotID, strings.Repeat("a", 40), profileID, profileDefinition, expiredAt.Add(-time.Minute), expiredAt); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE analysis_runs SET report_id=$2 WHERE id=$1`, expiredRunID, reportID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO notifications (id,workspace_id,analysis_run_id,channel_id,event_type,status) VALUES ($1,$2,$3,$4,'analysis.succeeded','delivered')`, notificationID, workspaceID, expiredRunID, channelID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workflow_dispatches (id,aggregate_type,aggregate_id,workflow_name,payload,status) VALUES ($1,'analysis_run',$2,'analysis-run','{}','dispatched')`, dispatchID, expiredRunID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO audit_events (id,workspace_id,action,resource_type,resource_id) VALUES ($1,$2,'analysis.created','analysis_run',$3)`, auditID, workspaceID, expiredRunID); err != nil { + t.Fatal(err) + } + + store := NewStore(pool) + cleanup := retention.Cleanup{ID: cleanupID, WorkspaceID: workspaceID, RetentionDays: 30, Status: "running"} + deleted, err := store.DeleteExpiredRuns(ctx, cleanup, 100) + if err != nil { + t.Fatal(err) + } + if deleted != 2 { + t.Fatalf("deleted runs = %d, want 2", deleted) + } + assertRowCount(t, pool, `SELECT count(*) FROM analysis_runs WHERE id = ANY($1)`, []uuid.UUID{expiredRunID, deletedRepositoryRunID}, 0) + assertRowCount(t, pool, `SELECT count(*) FROM analysis_runs WHERE id = ANY($1)`, []uuid.UUID{recentRunID, queuedRunID}, 2) + assertRowCount(t, pool, `SELECT count(*) FROM analysis_reports WHERE id=$1`, reportID, 0) + assertRowCount(t, pool, `SELECT count(*) FROM notifications WHERE id=$1`, notificationID, 0) + assertRowCount(t, pool, `SELECT count(*) FROM workflow_dispatches WHERE id=$1`, dispatchID, 1) + assertRowCount(t, pool, `SELECT count(*) FROM audit_events WHERE id=$1`, auditID, 1) + + pins, err := store.ClaimSnapshotPins(ctx, cleanup, 100) + if err != nil { + t.Fatal(err) + } + if len(pins) != 1 || pins[0].ID != expiredSnapshotID { + t.Fatalf("purgeable pins = %+v, want only expired snapshot", pins) + } + blockedRunID := uuid.New() + blockedDigest := sha256.Sum256(blockedRunID[:]) + _, err = analysisdata.NewStore(pool).Create(ctx, analysis.Run{ + ID: blockedRunID, WorkspaceID: workspaceID, RepositoryID: repositoryID, + SnapshotID: expiredSnapshotID, CommitSHA: strings.Repeat("a", 40), RequestedBy: userID, + DimensionKey: "code_scale", ProfileID: profileID, ProfileVersion: "v1", + ProfileSnapshot: profileDefinition, AnalyzerVersion: "test", + IdempotencyKey: hex.EncodeToString(blockedDigest[:]), Attempt: 1, Status: "queued", Stage: "queued", + }) + problem, ok := fault.From(err) + if !ok || problem.Code() != "analysis.snapshot_unavailable" { + t.Fatalf("analysis on purging snapshot error = %v", err) + } + if err := store.CompleteSnapshotPurge(ctx, cleanup, pins[0]); err != nil { + t.Fatal(err) + } + assertRowCount(t, pool, `SELECT count(*) FROM commit_snapshots WHERE id=$1 AND source_state='purged'`, expiredSnapshotID, 1) + + requeued, err := store.RequeueRepositoryPurges(ctx, cleanup, 100) + if err != nil { + t.Fatal(err) + } + if requeued != 1 { + t.Fatalf("requeued repositories = %d, want 1", requeued) + } + assertRowCount(t, pool, `SELECT count(*) FROM repositories WHERE id=$1 AND status='deleting'`, deletedRepositoryID, 1) + assertRowCount(t, pool, `SELECT count(*) FROM repository_operations WHERE repository_id=$1 AND kind='purge' AND status='queued'`, deletedRepositoryID, 1) + assertRowCount(t, pool, `SELECT count(*) FROM workflow_dispatches WHERE aggregate_type='repository_operation' AND status='pending'`, nil, 1) + + requeued, err = store.RequeueRepositoryPurges(ctx, cleanup, 100) + if err != nil || requeued != 0 { + t.Fatalf("second requeue = (%d, %v), want idempotent zero", requeued, err) + } +} + +func snapshotCommit(id uuid.UUID, commits map[uuid.UUID]string) string { + return commits[id] +} + +func assertRowCount(t *testing.T, pool *pgxpool.Pool, query string, argument any, want int) { + t.Helper() + + var count int + var err error + if argument == nil { + err = pool.QueryRow(context.Background(), query).Scan(&count) + } else { + err = pool.QueryRow(context.Background(), query, argument).Scan(&count) + } + if err != nil { + t.Fatal(err) + } + if count != want { + t.Fatalf("row count = %d, want %d for %s", count, want, query) + } +} diff --git a/internal/retention/workflow/options.go b/internal/retention/workflow/options.go new file mode 100644 index 0000000..2cec4de --- /dev/null +++ b/internal/retention/workflow/options.go @@ -0,0 +1,19 @@ +package workflow + +type Metrics interface { + AddRetentionProgress(deletedRuns, purgedSnapshots, requeuedRepositories int) +} + +type RunnerOption func(*Runner) + +func WithMetrics(metrics Metrics) RunnerOption { + return func(runner *Runner) { + if metrics != nil { + runner.metrics = metrics + } + } +} + +type discardMetrics struct{} + +func (discardMetrics) AddRetentionProgress(int, int, int) {} diff --git a/internal/retention/workflow/runner.go b/internal/retention/workflow/runner.go new file mode 100644 index 0000000..b710e8c --- /dev/null +++ b/internal/retention/workflow/runner.go @@ -0,0 +1,111 @@ +package workflow + +import ( + "context" + "errors" + + retention "github.com/fuchencong/mooncode/internal/retention/biz" + workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type Runner struct { + store retention.Store + git gitrepo.Manager + batchSize int32 + metrics Metrics +} + +func NewRunner(store retention.Store, git gitrepo.Manager, options ...RunnerOption) *Runner { + runner := &Runner{store: store, git: git, batchSize: 100, metrics: discardMetrics{}} + for _, option := range options { + option(runner) + } + + return runner +} + +func (r *Runner) Execute(ctx context.Context, cleanupID uuid.UUID) error { + cleanup, err := r.store.GetCleanup(ctx, cleanupID) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if cleanup.Status == "succeeded" { + return nil + } + if cleanup.Status == "failed" { + return workflowbiz.Permanent(errors.New("retention cleanup is already failed")) + } + + cleanup, err = r.store.StartCleanup(ctx, cleanupID) + if err != nil { + return err + } + if err = r.deleteExpiredRuns(ctx, cleanup); err != nil { + return err + } + if err = r.releaseSnapshotPins(ctx, cleanup); err != nil { + return err + } + if err = r.requeueRepositoryPurges(ctx, cleanup); err != nil { + return err + } + + return r.store.FinishCleanup(ctx, cleanup.ID) +} + +func (r *Runner) deleteExpiredRuns(ctx context.Context, cleanup retention.Cleanup) error { + for { + count, err := r.store.DeleteExpiredRuns(ctx, cleanup, r.batchSize) + if err != nil { + return err + } + r.metrics.AddRetentionProgress(count, 0, 0) + if count < int(r.batchSize) { + return nil + } + } +} + +func (r *Runner) releaseSnapshotPins(ctx context.Context, cleanup retention.Cleanup) error { + for { + pins, err := r.store.ClaimSnapshotPins(ctx, cleanup, r.batchSize) + if err != nil { + return err + } + for _, pin := range pins { + if err = r.git.ReleaseSnapshot(ctx, pin.RepositoryID, pin.ID, pin.CommitSHA); err != nil { + return err + } + if err = r.store.CompleteSnapshotPurge(ctx, cleanup, pin); err != nil { + return err + } + r.metrics.AddRetentionProgress(0, 1, 0) + } + if len(pins) < int(r.batchSize) { + return nil + } + } +} + +func (r *Runner) requeueRepositoryPurges(ctx context.Context, cleanup retention.Cleanup) error { + for { + count, err := r.store.RequeueRepositoryPurges(ctx, cleanup, r.batchSize) + if err != nil { + return err + } + r.metrics.AddRetentionProgress(0, 0, count) + if count < int(r.batchSize) { + return nil + } + } +} + +func (r *Runner) MarkFailed(ctx context.Context, cleanupID uuid.UUID, cause error) error { + return r.store.FailCleanup(ctx, cleanupID, cause.Error()) +} diff --git a/internal/retention/workflow/runner_test.go b/internal/retention/workflow/runner_test.go new file mode 100644 index 0000000..7dbf4cf --- /dev/null +++ b/internal/retention/workflow/runner_test.go @@ -0,0 +1,194 @@ +package workflow + +import ( + "context" + "errors" + "testing" + + retention "github.com/fuchencong/mooncode/internal/retention/biz" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/uuid" +) + +type retentionStore struct { + cleanup retention.Cleanup + deleteBatches []int + pinBatches [][]retention.SnapshotPin + repositoryBatches []int + started bool + finished bool + failed string + completedPins []uuid.UUID + scheduledBatches []int +} + +func (s *retentionStore) ScheduleDue(context.Context, int32) (int, error) { + value := s.scheduledBatches[0] + s.scheduledBatches = s.scheduledBatches[1:] + return value, nil +} + +func (s *retentionStore) GetCleanup(context.Context, uuid.UUID) (retention.Cleanup, error) { + return s.cleanup, nil +} + +func (s *retentionStore) StartCleanup(context.Context, uuid.UUID) (retention.Cleanup, error) { + s.started = true + s.cleanup.Status = "running" + return s.cleanup, nil +} + +func (s *retentionStore) DeleteExpiredRuns(context.Context, retention.Cleanup, int32) (int, error) { + value := s.deleteBatches[0] + s.deleteBatches = s.deleteBatches[1:] + return value, nil +} + +func (s *retentionStore) ClaimSnapshotPins(context.Context, retention.Cleanup, int32) ([]retention.SnapshotPin, error) { + value := s.pinBatches[0] + s.pinBatches = s.pinBatches[1:] + return value, nil +} + +func (s *retentionStore) CompleteSnapshotPurge(_ context.Context, _ retention.Cleanup, pin retention.SnapshotPin) error { + s.completedPins = append(s.completedPins, pin.ID) + return nil +} + +func (s *retentionStore) RequeueRepositoryPurges(context.Context, retention.Cleanup, int32) (int, error) { + value := s.repositoryBatches[0] + s.repositoryBatches = s.repositoryBatches[1:] + return value, nil +} + +func (s *retentionStore) FinishCleanup(context.Context, uuid.UUID) error { + s.finished = true + return nil +} + +func (s *retentionStore) FailCleanup(_ context.Context, _ uuid.UUID, message string) error { + s.failed = message + return nil +} + +type retentionGit struct { + released []uuid.UUID + err error +} + +type retentionMetrics struct { + deleted int + purged int + requeued int +} + +func (m *retentionMetrics) AddRetentionProgress(deleted, purged, requeued int) { + m.deleted += deleted + m.purged += purged + m.requeued += requeued +} + +func (*retentionGit) Provision(context.Context, uuid.UUID, string, string, uuid.UUID, gitrepo.Credential) (gitrepo.Snapshot, error) { + return gitrepo.Snapshot{}, nil +} + +func (*retentionGit) Sync(context.Context, uuid.UUID, string, string, uuid.UUID, gitrepo.Credential) (gitrepo.Snapshot, error) { + return gitrepo.Snapshot{}, nil +} + +func (*retentionGit) Checkout(context.Context, uuid.UUID, string) (string, func() error, error) { + return "", func() error { return nil }, nil +} + +func (g *retentionGit) ReleaseSnapshot(_ context.Context, _ uuid.UUID, snapshotID uuid.UUID, _ string) error { + if g.err != nil { + return g.err + } + g.released = append(g.released, snapshotID) + return nil +} + +func (*retentionGit) Purge(context.Context, uuid.UUID) error { return nil } +func (*retentionGit) Path(uuid.UUID) string { return "" } + +func TestRunnerCleansInBatchesAndRequeuesRepositoryWorkflow(t *testing.T) { + cleanup := retention.Cleanup{ID: uuid.New(), WorkspaceID: uuid.New(), Status: "queued", RetentionDays: 90} + pins := []retention.SnapshotPin{ + {ID: uuid.New(), RepositoryID: uuid.New(), CommitSHA: "1111111111111111111111111111111111111111"}, + {ID: uuid.New(), RepositoryID: uuid.New(), CommitSHA: "2222222222222222222222222222222222222222"}, + } + store := &retentionStore{ + cleanup: cleanup, deleteBatches: []int{2, 1}, pinBatches: [][]retention.SnapshotPin{pins, {}}, + repositoryBatches: []int{2, 1}, + } + git := &retentionGit{} + metrics := &retentionMetrics{} + runner := NewRunner(store, git, WithMetrics(metrics)) + runner.batchSize = 2 + + if err := runner.Execute(context.Background(), cleanup.ID); err != nil { + t.Fatal(err) + } + if !store.started || !store.finished || store.failed != "" { + t.Fatalf("unexpected cleanup lifecycle: started=%v finished=%v failed=%q", store.started, store.finished, store.failed) + } + if len(git.released) != len(pins) || len(store.completedPins) != len(pins) { + t.Fatalf("snapshot releases = %v, completed = %v", git.released, store.completedPins) + } + if len(store.deleteBatches) != 0 || len(store.pinBatches) != 0 || len(store.repositoryBatches) != 0 { + t.Fatal("runner did not drain all cleanup batches") + } + if metrics.deleted != 3 || metrics.purged != 2 || metrics.requeued != 3 { + t.Fatalf("retention metrics = %+v", metrics) + } +} + +func TestRunnerDoesNotFinishWhenSnapshotReleaseFails(t *testing.T) { + cleanup := retention.Cleanup{ID: uuid.New(), WorkspaceID: uuid.New(), Status: "queued", RetentionDays: 90} + pin := retention.SnapshotPin{ID: uuid.New(), RepositoryID: uuid.New(), CommitSHA: "1111111111111111111111111111111111111111"} + store := &retentionStore{ + cleanup: cleanup, deleteBatches: []int{0}, pinBatches: [][]retention.SnapshotPin{{pin}}, + repositoryBatches: []int{0}, + } + want := errors.New("git unavailable") + runner := NewRunner(store, &retentionGit{err: want}) + + err := runner.Execute(context.Background(), cleanup.ID) + if !errors.Is(err, want) { + t.Fatalf("Execute() error = %v, want %v", err, want) + } + if store.finished || len(store.completedPins) != 0 { + t.Fatal("failed Git release was projected as complete") + } + if err := runner.MarkFailed(context.Background(), cleanup.ID, want); err != nil { + t.Fatal(err) + } + if store.failed != want.Error() { + t.Fatalf("failure = %q, want %q", store.failed, want) + } +} + +func TestRunnerTreatsSucceededCleanupAsIdempotent(t *testing.T) { + cleanup := retention.Cleanup{ID: uuid.New(), WorkspaceID: uuid.New(), Status: "succeeded"} + store := &retentionStore{cleanup: cleanup} + + if err := NewRunner(store, &retentionGit{}).Execute(context.Background(), cleanup.ID); err != nil { + t.Fatal(err) + } + if store.started || store.finished { + t.Fatal("succeeded cleanup was executed again") + } +} + +func TestSchedulerDrainsDueWorkspaces(t *testing.T) { + store := &retentionStore{scheduledBatches: []int{2, 1}} + scheduler := NewScheduler(store) + scheduler.batchSize = 2 + + if err := scheduler.Execute(context.Background()); err != nil { + t.Fatal(err) + } + if len(store.scheduledBatches) != 0 { + t.Fatal("scheduler did not drain due workspace batches") + } +} diff --git a/internal/retention/workflow/scheduler.go b/internal/retention/workflow/scheduler.go new file mode 100644 index 0000000..734c21d --- /dev/null +++ b/internal/retention/workflow/scheduler.go @@ -0,0 +1,28 @@ +package workflow + +import ( + "context" + + retention "github.com/fuchencong/mooncode/internal/retention/biz" +) + +type Scheduler struct { + store retention.Store + batchSize int32 +} + +func NewScheduler(store retention.Store) *Scheduler { + return &Scheduler{store: store, batchSize: 100} +} + +func (s *Scheduler) Execute(ctx context.Context) error { + for { + count, err := s.store.ScheduleDue(ctx, s.batchSize) + if err != nil { + return err + } + if count < int(s.batchSize) { + return nil + } + } +} diff --git a/internal/sanitize/error.go b/internal/sanitize/error.go deleted file mode 100644 index a9dd55d..0000000 --- a/internal/sanitize/error.go +++ /dev/null @@ -1,34 +0,0 @@ -// Package sanitize removes credentials and unbounded data from persisted errors. -package sanitize - -import ( - "regexp" - "strings" - "unicode/utf8" -) - -var ( - urlPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) - secretPattern = regexp.MustCompile(`(?i)(authorization|cookie|token|secret|password|signature|webhook)[=:][^\s,;]+`) -) - -func ErrorMessage(err error, maxBytes int) string { - if err == nil || maxBytes <= 0 { - return "" - } - message := urlPattern.ReplaceAllString(err.Error(), "[redacted-url]") - message = secretPattern.ReplaceAllStringFunc(message, func(value string) string { - if index := strings.IndexAny(value, "=:"); index >= 0 { - return value[:index+1] + "[REDACTED]" - } - return "[REDACTED]" - }) - if len(message) <= maxBytes { - return message - } - message = message[:maxBytes] - for !utf8.ValidString(message) { - message = message[:len(message)-1] - } - return message -} diff --git a/internal/sanitize/error_test.go b/internal/sanitize/error_test.go deleted file mode 100644 index 0cb39dd..0000000 --- a/internal/sanitize/error_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package sanitize - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestErrorMessageRedactsURLsSecretsAndUTF8Safely(t *testing.T) { - message := ErrorMessage(errors.New("connect https://example.test/hook/private?token=abc token=secret 密钥"), 60) - require.NotContains(t, message, "private") - require.NotContains(t, message, "abc") - require.NotContains(t, message, "secret") - require.LessOrEqual(t, len(message), 60) -} diff --git a/internal/secretstore/encrypted.go b/internal/secretstore/encrypted.go deleted file mode 100644 index 19e9864..0000000 --- a/internal/secretstore/encrypted.go +++ /dev/null @@ -1,237 +0,0 @@ -package secretstore - -import ( - "context" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/json" - "errors" - "fmt" - "io" - "sort" - "strings" - - "github.com/google/uuid" -) - -type Option func(*options) error - -type options struct { - currentVersion int32 - masterKeys map[int32][]byte - random io.Reader -} - -func WithMasterKey(version int32, key []byte) Option { - return func(cfg *options) error { - if version <= 0 { - return errors.New("secretstore master key version must be positive") - } - if len(key) != 32 { - return errors.New("secretstore master key must be 32 bytes") - } - cfg.masterKeys[version] = append([]byte(nil), key...) - if version > cfg.currentVersion { - cfg.currentVersion = version - } - return nil - } -} - -func WithCurrentKeyVersion(version int32) Option { - return func(cfg *options) error { - if version <= 0 { - return errors.New("secretstore current key version must be positive") - } - cfg.currentVersion = version - return nil - } -} - -func WithRandom(source io.Reader) Option { - return func(cfg *options) error { - if source == nil { - return errors.New("secretstore random source cannot be nil") - } - cfg.random = source - return nil - } -} - -type EncryptedStore struct { - records RecordStore - currentVersion int32 - masterKeys map[int32][]byte - random io.Reader -} - -func NewEncryptedStore(records RecordStore, opts ...Option) (*EncryptedStore, error) { - if records == nil { - return nil, errors.New("secretstore record store is required") - } - cfg := options{masterKeys: make(map[int32][]byte), random: rand.Reader} - for _, option := range opts { - if option != nil { - if err := option(&cfg); err != nil { - return nil, err - } - } - } - if _, ok := cfg.masterKeys[cfg.currentVersion]; !ok { - return nil, fmt.Errorf("secretstore current master key version %d is not configured", cfg.currentVersion) - } - return &EncryptedStore{records: records, currentVersion: cfg.currentVersion, masterKeys: cfg.masterKeys, random: cfg.random}, nil -} - -func (s *EncryptedStore) Put(ctx context.Context, scope Scope, values SecretValues) (SecretRef, error) { - if err := scope.Validate(); err != nil { - return SecretRef{}, err - } - plaintext, err := marshalValues(values) - if err != nil { - return SecretRef{}, err - } - dataKey := make([]byte, 32) - if _, err := io.ReadFull(s.random, dataKey); err != nil { - return SecretRef{}, fmt.Errorf("generate secret data key: %w", err) - } - defer clear(dataKey) - aad := associatedData(scope) - ciphertext, nonce, err := seal(dataKey, plaintext, aad, s.random) - clear(plaintext) - if err != nil { - return SecretRef{}, err - } - masterKey := s.masterKeys[s.currentVersion] - wrappedKey, wrappedNonce, err := seal(masterKey, dataKey, aad, s.random) - if err != nil { - return SecretRef{}, err - } - id, err := uuid.NewV7() - if err != nil { - return SecretRef{}, fmt.Errorf("generate secret ID: %w", err) - } - record := Record{ID: id, Scope: scope, Ciphertext: ciphertext, Nonce: nonce, WrappedKey: wrappedKey, WrappedKeyNonce: wrappedNonce, KeyVersion: s.currentVersion} - if err := s.records.CreateSecretRecord(ctx, record); err != nil { - return SecretRef{}, fmt.Errorf("persist encrypted secret: %w", err) - } - return SecretRef{ID: id, Scope: scope}, nil -} - -func (s *EncryptedStore) Get(ctx context.Context, ref SecretRef) (SecretValues, error) { - if err := validateRef(ref); err != nil { - return nil, err - } - record, err := s.records.GetSecretRecord(ctx, ref.ID, ref.Scope.WorkspaceID) - if err != nil { - return nil, err - } - if record.Scope != ref.Scope { - return nil, ErrDecrypt - } - masterKey, ok := s.masterKeys[record.KeyVersion] - if !ok { - return nil, fmt.Errorf("%w: unknown key version", ErrDecrypt) - } - aad := associatedData(ref.Scope) - dataKey, err := open(masterKey, record.WrappedKeyNonce, record.WrappedKey, aad) - if err != nil { - return nil, ErrDecrypt - } - defer clear(dataKey) - plaintext, err := open(dataKey, record.Nonce, record.Ciphertext, aad) - if err != nil { - return nil, ErrDecrypt - } - defer clear(plaintext) - values, err := unmarshalValues(plaintext) - if err != nil { - return nil, ErrDecrypt - } - return values, nil -} - -func (s *EncryptedStore) Delete(ctx context.Context, ref SecretRef) error { - if err := validateRef(ref); err != nil { - return err - } - return s.records.DeleteSecretRecord(ctx, ref.ID, ref.Scope.WorkspaceID) -} - -func seal(key, plaintext, aad []byte, random io.Reader) ([]byte, []byte, error) { - aead, err := newGCM(key) - if err != nil { - return nil, nil, err - } - nonce := make([]byte, aead.NonceSize()) - if _, err := io.ReadFull(random, nonce); err != nil { - return nil, nil, fmt.Errorf("generate encryption nonce: %w", err) - } - return aead.Seal(nil, nonce, plaintext, aad), nonce, nil -} - -func open(key, nonce, ciphertext, aad []byte) ([]byte, error) { - aead, err := newGCM(key) - if err != nil { - return nil, err - } - return aead.Open(nil, nonce, ciphertext, aad) -} - -func newGCM(key []byte) (cipher.AEAD, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, fmt.Errorf("create AES cipher: %w", err) - } - aead, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("create AES-GCM: %w", err) - } - return aead, nil -} - -func associatedData(scope Scope) []byte { - return []byte(scope.WorkspaceID.String() + "\x00" + scope.ResourceType + "\x00" + scope.ResourceID.String()) -} - -func marshalValues(values SecretValues) ([]byte, error) { - if len(values) == 0 { - return nil, errors.New("secret values cannot be empty") - } - plain := make(map[string]string, len(values)) - keys := make([]string, 0, len(values)) - for key, value := range values { - key = strings.TrimSpace(key) - if key == "" { - return nil, errors.New("secret field name cannot be empty") - } - keys = append(keys, key) - plain[key] = value.Reveal() - } - // Deterministic key order makes tests and forensic checks reproducible; the - // random nonce still guarantees probabilistic encryption. - sort.Strings(keys) - ordered := make([]struct{ Key, Value string }, 0, len(keys)) - for _, key := range keys { - ordered = append(ordered, struct{ Key, Value string }{key, plain[key]}) - } - return json.Marshal(ordered) -} - -func unmarshalValues(data []byte) (SecretValues, error) { - var ordered []struct{ Key, Value string } - if err := json.Unmarshal(data, &ordered); err != nil { - return nil, err - } - values := make(SecretValues, len(ordered)) - for _, item := range ordered { - if item.Key == "" { - return nil, errors.New("empty secret field") - } - values[item.Key] = NewSecretString(item.Value) - } - return values, nil -} - -var _ SecretStore = (*EncryptedStore)(nil) diff --git a/internal/secretstore/encrypted_test.go b/internal/secretstore/encrypted_test.go deleted file mode 100644 index 96290b0..0000000 --- a/internal/secretstore/encrypted_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package secretstore - -import ( - "context" - "fmt" - "sync" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -type memoryRecords struct { - mu sync.Mutex - records map[uuid.UUID]Record -} - -func newMemoryRecords() *memoryRecords { return &memoryRecords{records: make(map[uuid.UUID]Record)} } -func (m *memoryRecords) CreateSecretRecord(_ context.Context, record Record) error { - m.mu.Lock() - defer m.mu.Unlock() - m.records[record.ID] = record - return nil -} -func (m *memoryRecords) GetSecretRecord(_ context.Context, id, workspaceID uuid.UUID) (Record, error) { - m.mu.Lock() - defer m.mu.Unlock() - record, ok := m.records[id] - if !ok || record.Scope.WorkspaceID != workspaceID { - return Record{}, ErrNotFound - } - return record, nil -} -func (m *memoryRecords) DeleteSecretRecord(_ context.Context, id, workspaceID uuid.UUID) error { - m.mu.Lock() - defer m.mu.Unlock() - record, ok := m.records[id] - if ok && record.Scope.WorkspaceID == workspaceID { - delete(m.records, id) - } - return nil -} - -func TestEncryptedStoreRoundTripAndRedaction(t *testing.T) { - records := newMemoryRecords() - store, err := NewEncryptedStore(records, WithMasterKey(1, []byte("0123456789abcdef0123456789abcdef"))) - require.NoError(t, err) - scope := Scope{WorkspaceID: uuid.Must(uuid.NewV7()), ResourceType: "repository", ResourceID: uuid.Must(uuid.NewV7())} - values := SecretValues{"token": NewSecretString("super-secret"), "username": NewSecretString("git")} - ref, err := store.Put(context.Background(), scope, values) - require.NoError(t, err) - require.NotContains(t, string(records.records[ref.ID].Ciphertext), "super-secret") - loaded, err := store.Get(context.Background(), ref) - require.NoError(t, err) - require.Equal(t, "super-secret", loaded["token"].Reveal()) - require.Equal(t, "[REDACTED]", fmt.Sprint(loaded["token"])) -} - -func TestEncryptedStoreRejectsScopeSubstitution(t *testing.T) { - store, err := NewEncryptedStore(newMemoryRecords(), WithMasterKey(1, []byte("0123456789abcdef0123456789abcdef"))) - require.NoError(t, err) - scope := Scope{WorkspaceID: uuid.Must(uuid.NewV7()), ResourceType: "channel", ResourceID: uuid.Must(uuid.NewV7())} - ref, err := store.Put(context.Background(), scope, SecretValues{"token": NewSecretString("secret")}) - require.NoError(t, err) - ref.Scope.ResourceID = uuid.Must(uuid.NewV7()) - _, err = store.Get(context.Background(), ref) - require.ErrorIs(t, err, ErrDecrypt) -} - -func TestEncryptedStoreSupportsOldKeyVersion(t *testing.T) { - records := newMemoryRecords() - oldStore, err := NewEncryptedStore(records, WithMasterKey(1, []byte("0123456789abcdef0123456789abcdef"))) - require.NoError(t, err) - scope := Scope{WorkspaceID: uuid.Must(uuid.NewV7()), ResourceType: "scm", ResourceID: uuid.Must(uuid.NewV7())} - ref, err := oldStore.Put(context.Background(), scope, SecretValues{"token": NewSecretString("old")}) - require.NoError(t, err) - rotated, err := NewEncryptedStore(records, - WithMasterKey(1, []byte("0123456789abcdef0123456789abcdef")), - WithMasterKey(2, []byte("abcdef0123456789abcdef0123456789")), - WithCurrentKeyVersion(2), - ) - require.NoError(t, err) - values, err := rotated.Get(context.Background(), ref) - require.NoError(t, err) - require.Equal(t, "old", values["token"].Reveal()) -} diff --git a/internal/secretstore/secretstore.go b/internal/secretstore/secretstore.go deleted file mode 100644 index cab0ab0..0000000 --- a/internal/secretstore/secretstore.go +++ /dev/null @@ -1,81 +0,0 @@ -// Package secretstore provides workspace-scoped application secret storage. -// Secret plaintext is envelope-encrypted before it reaches the persistence -// adapter. -package secretstore - -import ( - "context" - "errors" - "fmt" - - "github.com/google/uuid" -) - -var ( - ErrNotFound = errors.New("secretstore: not found") - ErrInvalidScope = errors.New("secretstore: invalid scope") - ErrDecrypt = errors.New("secretstore: decrypt failed") -) - -// Scope binds a secret to exactly one MoonCode resource. -type Scope struct { - WorkspaceID uuid.UUID - ResourceType string - ResourceID uuid.UUID -} - -func (s Scope) Validate() error { - if s.WorkspaceID == uuid.Nil || s.ResourceID == uuid.Nil || s.ResourceType == "" { - return ErrInvalidScope - } - return nil -} - -// SecretRef is safe to persist in ordinary resource rows. It contains no -// plaintext and carries the scope required to authenticate decryption. -type SecretRef struct { - ID uuid.UUID `json:"id"` - Scope Scope `json:"-"` -} - -// SecretString prevents common formatting paths from exposing plaintext. -type SecretString struct{ value string } - -func NewSecretString(value string) SecretString { return SecretString{value: value} } -func (s SecretString) Reveal() string { return s.value } -func (SecretString) String() string { return "[REDACTED]" } -func (SecretString) GoString() string { return "secretstore.SecretString{[REDACTED]}" } - -// SecretValues is a named set of secret fields such as token or app_secret. -type SecretValues map[string]SecretString - -type SecretStore interface { - Put(ctx context.Context, scope Scope, values SecretValues) (SecretRef, error) - Get(ctx context.Context, ref SecretRef) (SecretValues, error) - Delete(ctx context.Context, ref SecretRef) error -} - -// Record is the encrypted persistence representation. Plaintext must never be -// added to this type. -type Record struct { - ID uuid.UUID - Scope Scope - Ciphertext []byte - Nonce []byte - WrappedKey []byte - WrappedKeyNonce []byte - KeyVersion int32 -} - -type RecordStore interface { - CreateSecretRecord(ctx context.Context, record Record) error - GetSecretRecord(ctx context.Context, id, workspaceID uuid.UUID) (Record, error) - DeleteSecretRecord(ctx context.Context, id, workspaceID uuid.UUID) error -} - -func validateRef(ref SecretRef) error { - if ref.ID == uuid.Nil { - return fmt.Errorf("%w: secret ID is required", ErrInvalidScope) - } - return ref.Scope.Validate() -} diff --git a/internal/service/audit.go b/internal/service/audit.go deleted file mode 100644 index 617308a..0000000 --- a/internal/service/audit.go +++ /dev/null @@ -1,55 +0,0 @@ -package service - -import ( - "context" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" -) - -type AuditService struct { - store repository.MetadataStore - identities *IdentityService -} - -type AuditServiceOption func(*AuditService) - -type AuditPage struct { - Items []model.AuditLog `json:"items"` - NextCursor string `json:"nextCursor,omitempty"` -} - -func NewAuditService(store repository.MetadataStore, identities *IdentityService, opts ...AuditServiceOption) *AuditService { - service := &AuditService{store: store, identities: identities} - for _, option := range opts { - if option != nil { - option(service) - } - } - return service -} - -func (s *AuditService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, cursor string, pageSize int32) (AuditPage, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil { - return AuditPage{}, err - } - if pageSize <= 0 || pageSize > 100 { - pageSize = 50 - } - decoded, err := decodeTimeCursor(cursor) - if err != nil { - return AuditPage{}, err - } - items, err := s.store.ListAuditLogs(ctx, workspaceID, decoded, pageSize+1) - if err != nil { - return AuditPage{}, err - } - page := AuditPage{Items: items} - if len(items) > int(pageSize) { - page.Items = items[:pageSize] - last := page.Items[len(page.Items)-1] - page.NextCursor = encodeTimeCursor(last.OccurredAt, last.ID) - } - return page, nil -} diff --git a/internal/service/audit_failure.go b/internal/service/audit_failure.go deleted file mode 100644 index 4e55ac9..0000000 --- a/internal/service/audit_failure.go +++ /dev/null @@ -1,21 +0,0 @@ -package service - -import ( - "context" - "time" - - "github.com/google/uuid" -) - -type failureAuditWriter interface { - AppendAuditResult(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, string, []byte) error -} - -func recordFailure(ctx context.Context, writer failureAuditWriter, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, resultErr error) { - if resultErr == nil || writer == nil || workspaceID == uuid.Nil || actorID == uuid.Nil { - return - } - auditCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) - defer cancel() - _ = writer.AppendAuditResult(auditCtx, workspaceID, actorID, action, resourceType, resourceID, "failure", []byte(`{"code":"operation_failed"}`)) -} diff --git a/internal/service/audit_failure_test.go b/internal/service/audit_failure_test.go deleted file mode 100644 index e40fb75..0000000 --- a/internal/service/audit_failure_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package service - -import ( - "context" - "errors" - "sync" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -type recordedFailureAudit struct { - mu sync.Mutex - called int - ctxErr error - resourceID uuid.UUID - result string -} - -func (w *recordedFailureAudit) AppendAuditResult(ctx context.Context, _, _ uuid.UUID, _, _ string, resourceID uuid.UUID, result string, _ []byte) error { - w.mu.Lock() - defer w.mu.Unlock() - w.called++ - w.ctxErr = ctx.Err() - w.resourceID = resourceID - w.result = result - return nil -} - -func TestRecordFailureUsesDetachedBoundedContext(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - writer := &recordedFailureAudit{} - - recordFailure(ctx, writer, uuid.New(), uuid.New(), "repository.create", "repository", uuid.Nil, errors.New("failed")) - - require.Equal(t, 1, writer.called) - require.NoError(t, writer.ctxErr) - require.Equal(t, uuid.Nil, writer.resourceID) - require.Equal(t, "failure", writer.result) -} - -func TestRecordFailureSkipsSuccessfulOrUnscopedOperations(t *testing.T) { - writer := &recordedFailureAudit{} - recordFailure(context.Background(), writer, uuid.New(), uuid.New(), "test", "resource", uuid.New(), nil) - recordFailure(context.Background(), writer, uuid.Nil, uuid.New(), "test", "resource", uuid.New(), errors.New("failed")) - recordFailure(context.Background(), writer, uuid.New(), uuid.Nil, "test", "resource", uuid.New(), errors.New("failed")) - require.Zero(t, writer.called) -} diff --git a/internal/service/authorization_test.go b/internal/service/authorization_test.go deleted file mode 100644 index b89f15c..0000000 --- a/internal/service/authorization_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package service - -import ( - "fmt" - "testing" - - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/repository/memory" - "github.com/stretchr/testify/require" -) - -func TestWorkspaceAuthorizationRoleMatrixAndTenantIsolation(t *testing.T) { - store := memory.New() - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg) - registrations := NewRegistrationService(store, identities, cfg) - workspaces := NewWorkspaceService(store, identities) - principals := make(map[string]model.Principal) - for _, role := range []string{"owner", "admin", "member", "viewer", "outsider"} { - principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: "test", Subject: role, Username: role, Email: role + "@example.com"}) - require.NoError(t, err) - session, err := registrations.Complete(t.Context(), principal, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true}) - require.NoError(t, err) - principals[role] = model.Principal{User: session.User} - } - workspace, err := workspaces.Create(t.Context(), principals["owner"], "role matrix") - require.NoError(t, err) - for _, role := range []string{"admin", "member", "viewer"} { - principals[role] = inviteTestMember(t, registrations, principals["owner"], principals[role], workspace.ID, role) - } - - tests := []struct { - name string - allowed []string - roles []string - }{ - {name: "read", allowed: []string{"owner", "admin", "member", "viewer"}}, - {name: "mutate resource", roles: []string{"owner", "admin", "member"}, allowed: []string{"owner", "admin", "member"}}, - {name: "administer workspace", roles: []string{"owner", "admin"}, allowed: []string{"owner", "admin"}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - for _, role := range []string{"owner", "admin", "member", "viewer", "outsider"} { - _, authErr := identities.AuthorizeWorkspace(t.Context(), principals[role], workspace.ID, test.roles...) - expected := containsRole(test.allowed, role) - if expected { - require.NoError(t, authErr, role) - } else { - require.ErrorIs(t, authErr, repository.ErrNotFound, fmt.Sprintf("role %s must not discover the resource", role)) - } - } - }) - } -} - -func containsRole(values []string, target string) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} diff --git a/internal/service/channel.go b/internal/service/channel.go deleted file mode 100644 index cd8cd1b..0000000 --- a/internal/service/channel.go +++ /dev/null @@ -1,367 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/secretstore" - channelcore "github.com/mooncode-ai/mooncode/pkg/channel" -) - -type CreateChannelInput struct { - Type string - Name string - Values map[string]any - Secrets map[string]string - SenderAllowList []string - GroupPolicy channelcore.GroupPolicy - IdempotencyKey string -} -type UpdateChannelInput struct { - Version int64 - Name *string - Values map[string]any - Secrets map[string]string - ClearSecretFields []string - SenderAllowList *[]string - GroupPolicy *channelcore.GroupPolicy - IdempotencyKey string -} -type ChannelService struct { - store repository.ChannelStore - secrets secretstore.SecretStore - registry *channelcore.Registry - identities *IdentityService - clock func() time.Time - cfg config.ChannelsConfig -} -type ChannelServiceOption func(*ChannelService) - -func WithChannelServiceClock(clock func() time.Time) ChannelServiceOption { - return func(service *ChannelService) { - if clock != nil { - service.clock = clock - } - } -} - -func NewChannelService(store repository.ChannelStore, secrets secretstore.SecretStore, registry *channelcore.Registry, identities *IdentityService, cfg config.Config, opts ...ChannelServiceOption) *ChannelService { - service := &ChannelService{store: store, secrets: secrets, registry: registry, identities: identities, clock: time.Now, cfg: cfg.Channels} - for _, option := range opts { - if option != nil { - option(service) - } - } - return service -} -func (s *ChannelService) Descriptors() []channelcore.Descriptor { return s.registry.Descriptors() } - -func (s *ChannelService) Create(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, input CreateChannelInput) (instance model.ChannelInstance, resultErr error) { - resourceID := uuid.Nil - defer func() { - recordFailure(ctx, s.store, workspaceID, principal.User.ID, "channel.create", "channel_instance", resourceID, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return model.ChannelInstance{}, err - } - input.Type, input.Name = strings.ToLower(strings.TrimSpace(input.Type)), strings.TrimSpace(input.Name) - if input.Name == "" { - return model.ChannelInstance{}, errors.New("channel name is required") - } - factory, ok := s.registry.Factory(input.Type) - if !ok { - return model.ChannelInstance{}, errors.New("unknown channel type") - } - id, err := uuid.NewV7() - if err != nil { - return model.ChannelInstance{}, err - } - resourceID = id - instanceConfig := channelcore.InstanceConfig{AccountID: id.String(), Values: input.Values, Secrets: input.Secrets, SenderAllowList: input.SenderAllowList, GroupPolicy: input.GroupPolicy} - if err := factory.Validate(instanceConfig); err != nil { - return model.ChannelInstance{}, err - } - instance = model.ChannelInstance{ID: id, WorkspaceID: workspaceID, Type: input.Type, Name: input.Name, Config: model.ChannelConfig{Values: cloneValues(input.Values), SenderAllowList: append([]string(nil), input.SenderAllowList...), GroupPolicy: input.GroupPolicy}} - mutation, err := newMutationRequest(workspaceID, "channel.create", "name:"+strings.ToLower(input.Name), input.IdempotencyKey, input, id, s.cfg.MutationTimeout, s.cfg.MutationCooldown, s.clock().UTC()) - if err != nil { - return model.ChannelInstance{}, err - } - claim, err := s.store.BeginMutation(ctx, mutation) - if err != nil { - return model.ChannelInstance{}, err - } - if claim.Replay { - return s.store.GetChannel(ctx, workspaceID, claim.ResourceID) - } - succeeded := false - defer func() { finishMutation(s.store, mutation, succeeded) }() - var ref secretstore.SecretRef - if len(input.Secrets) > 0 { - ref, err = s.secrets.Put(ctx, secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "channel_instance", ResourceID: id}, secretValues(input.Secrets)) - if err != nil { - return model.ChannelInstance{}, err - } - instance.SecretRef = &ref.ID - } - err = s.store.WithinChannelTx(ctx, func(tx repository.ChannelMutationStore) error { - var txErr error - instance, txErr = tx.CreateChannel(ctx, instance) - if txErr != nil { - return txErr - } - metadata, _ := json.Marshal(map[string]string{"type": input.Type}) - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "channel.create", "channel_instance", id, metadata) - }) - if err != nil && ref.ID != uuid.Nil { - _ = s.secrets.Delete(ctx, ref) - } - succeeded = err == nil - return instance, err -} - -func (s *ChannelService) Update(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, input UpdateChannelInput) (instance model.ChannelInstance, resultErr error) { - defer func() { - recordFailure(ctx, s.store, workspaceID, principal.User.ID, "channel.update", "channel_instance", id, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return model.ChannelInstance{}, err - } - instance, err := s.store.GetChannel(ctx, workspaceID, id) - if err != nil { - return model.ChannelInstance{}, err - } - if input.Version != instance.ConfigVersion { - return model.ChannelInstance{}, repository.ErrConflict - } - if input.Name != nil { - instance.Name = strings.TrimSpace(*input.Name) - if instance.Name == "" { - return model.ChannelInstance{}, errors.New("channel name is required") - } - } - if input.Values != nil { - instance.Config.Values = cloneValues(input.Values) - } - if input.SenderAllowList != nil { - instance.Config.SenderAllowList = append([]string(nil), (*input.SenderAllowList)...) - } - if input.GroupPolicy != nil { - instance.Config.GroupPolicy = *input.GroupPolicy - } - currentSecrets := map[string]string{} - oldRef := secretstore.SecretRef{} - if instance.SecretRef != nil { - oldRef = secretstore.SecretRef{ID: *instance.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "channel_instance", ResourceID: id}} - values, getErr := s.secrets.Get(ctx, oldRef) - if getErr != nil { - return model.ChannelInstance{}, getErr - } - for key, value := range values { - currentSecrets[key] = value.Reveal() - } - } - for _, key := range input.ClearSecretFields { - delete(currentSecrets, key) - } - for key, value := range input.Secrets { - currentSecrets[key] = value - } - factory, _ := s.registry.Factory(instance.Type) - if err := factory.Validate(channelcore.InstanceConfig{AccountID: id.String(), Values: instance.Config.Values, Secrets: currentSecrets, SenderAllowList: instance.Config.SenderAllowList, GroupPolicy: instance.Config.GroupPolicy}); err != nil { - return model.ChannelInstance{}, err - } - mutation, err := newMutationRequest(workspaceID, "channel.update", id.String(), input.IdempotencyKey, input, id, s.cfg.MutationTimeout, s.cfg.MutationCooldown, s.clock().UTC()) - if err != nil { - return model.ChannelInstance{}, err - } - claim, err := s.store.BeginMutation(ctx, mutation) - if err != nil { - return model.ChannelInstance{}, err - } - if claim.Replay { - return s.store.GetChannel(ctx, workspaceID, id) - } - succeeded := false - defer func() { finishMutation(s.store, mutation, succeeded) }() - newRef := secretstore.SecretRef{} - if len(input.Secrets) > 0 || len(input.ClearSecretFields) > 0 { - if len(currentSecrets) > 0 { - scope := oldRef.Scope - if scope.ResourceID == uuid.Nil { - scope = secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "channel_instance", ResourceID: id} - } - newRef, err = s.secrets.Put(ctx, scope, secretValues(currentSecrets)) - if err != nil { - return model.ChannelInstance{}, err - } - instance.SecretRef = &newRef.ID - } else { - instance.SecretRef = nil - } - } - err = s.store.WithinChannelTx(ctx, func(tx repository.ChannelMutationStore) error { - var txErr error - instance, txErr = tx.UpdateChannel(ctx, instance, input.Version) - if txErr != nil { - return txErr - } - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "channel.update", "channel_instance", id, []byte(`{}`)) - }) - if err != nil { - if newRef.ID != uuid.Nil { - _ = s.secrets.Delete(ctx, newRef) - } - return model.ChannelInstance{}, err - } - if newRef.ID != uuid.Nil && oldRef.ID != uuid.Nil { - _ = s.secrets.Delete(ctx, oldRef) - } - succeeded = true - return instance, nil -} - -func (s *ChannelService) SetEnabled(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, enabled bool, idempotencyKey string) (instance model.ChannelInstance, resultErr error) { - action := "channel.disable" - if enabled { - action = "channel.enable" - } - defer func() { - recordFailure(ctx, s.store, workspaceID, principal.User.ID, action, "channel_instance", id, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return model.ChannelInstance{}, err - } - instance, err := s.store.GetChannel(ctx, workspaceID, id) - if err != nil { - return model.ChannelInstance{}, err - } - if enabled { - config, err := s.runtimeConfig(ctx, instance) - if err != nil { - return model.ChannelInstance{}, err - } - factory, ok := s.registry.Factory(instance.Type) - if !ok { - return model.ChannelInstance{}, errors.New("unknown channel type") - } - if err := factory.Validate(config); err != nil { - return model.ChannelInstance{}, err - } - } - mutation, err := newMutationRequest(workspaceID, action, id.String(), idempotencyKey, map[string]bool{"enabled": enabled}, id, s.cfg.MutationTimeout, s.cfg.MutationCooldown, s.clock().UTC()) - if err != nil { - return model.ChannelInstance{}, err - } - claim, err := s.store.BeginMutation(ctx, mutation) - if err != nil { - return model.ChannelInstance{}, err - } - if claim.Replay { - return s.store.GetChannel(ctx, workspaceID, id) - } - succeeded := false - defer func() { finishMutation(s.store, mutation, succeeded) }() - err = s.store.WithinChannelTx(ctx, func(tx repository.ChannelMutationStore) error { - var txErr error - instance, txErr = tx.SetChannelEnabled(ctx, workspaceID, id, enabled) - if txErr != nil { - return txErr - } - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, action, "channel_instance", id, []byte(`{}`)) - }) - succeeded = err == nil - return instance, err -} -func (s *ChannelService) Delete(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, idempotencyKey string) (resultErr error) { - defer func() { - recordFailure(ctx, s.store, workspaceID, principal.User.ID, "channel.delete", "channel_instance", id, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil { - return err - } - mutation, err := newMutationRequest(workspaceID, "channel.delete", id.String(), idempotencyKey, map[string]string{"channelId": id.String()}, id, s.cfg.MutationTimeout, s.cfg.MutationCooldown, s.clock().UTC()) - if err != nil { - return err - } - claim, err := s.store.BeginMutation(ctx, mutation) - if err != nil { - return err - } - if claim.Replay { - return nil - } - succeeded := false - defer func() { finishMutation(s.store, mutation, succeeded) }() - instance, err := s.store.GetChannel(ctx, workspaceID, id) - if err != nil { - return err - } - err = s.store.WithinChannelTx(ctx, func(tx repository.ChannelMutationStore) error { - if _, txErr := tx.SoftDeleteChannel(ctx, workspaceID, id); txErr != nil { - return txErr - } - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "channel.delete", "channel_instance", id, []byte(`{}`)) - }) - if err != nil { - return err - } - succeeded = true - if instance.SecretRef != nil { - _ = s.secrets.Delete(ctx, secretstore.SecretRef{ID: *instance.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "channel_instance", ResourceID: id}}) - } - return nil -} -func (s *ChannelService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.ChannelInstance, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return nil, err - } - return s.store.ListChannels(ctx, workspaceID) -} -func (s *ChannelService) Status(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID) (model.ChannelRuntimeStatus, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return model.ChannelRuntimeStatus{}, err - } - if _, err := s.store.GetChannel(ctx, workspaceID, id); err != nil { - return model.ChannelRuntimeStatus{}, err - } - status, err := s.store.GetChannelStatus(ctx, id) - if errors.Is(err, repository.ErrNotFound) { - return model.ChannelRuntimeStatus{ChannelInstanceID: id, State: "disabled"}, nil - } - return status, err -} -func (s *ChannelService) runtimeConfig(ctx context.Context, instance model.ChannelInstance) (channelcore.InstanceConfig, error) { - values := map[string]string{} - if instance.SecretRef != nil { - ref := secretstore.SecretRef{ID: *instance.SecretRef, Scope: secretstore.Scope{WorkspaceID: instance.WorkspaceID, ResourceType: "channel_instance", ResourceID: instance.ID}} - secrets, err := s.secrets.Get(ctx, ref) - if err != nil { - return channelcore.InstanceConfig{}, err - } - for key, value := range secrets { - values[key] = value.Reveal() - } - } - return channelcore.InstanceConfig{AccountID: instance.ID.String(), Values: cloneValues(instance.Config.Values), Secrets: values, SenderAllowList: append([]string(nil), instance.Config.SenderAllowList...), GroupPolicy: instance.Config.GroupPolicy}, nil -} -func secretValues(values map[string]string) secretstore.SecretValues { - result := make(secretstore.SecretValues, len(values)) - for key, value := range values { - result[key] = secretstore.NewSecretString(value) - } - return result -} -func cloneValues(values map[string]any) map[string]any { - result := make(map[string]any, len(values)) - for key, value := range values { - result[key] = value - } - return result -} diff --git a/internal/service/identity.go b/internal/service/identity.go deleted file mode 100644 index 539ab06..0000000 --- a/internal/service/identity.go +++ /dev/null @@ -1,154 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" -) - -var ( - ErrSubjectRequired = errors.New("identity subject is required") - ErrRegistrationClosed = errors.New("registration is closed") - ErrOnboardingRequired = errors.New("account onboarding is required") - ErrAccountSuspended = errors.New("account is suspended") - ErrAccountDeleted = errors.New("account is deleted") - ErrInvitationInvalid = errors.New("invitation is invalid") - ErrInsufficientRole = errors.New("insufficient workspace role") -) - -type IdentityService struct { - store repository.IdentityStore - cfg config.Config - clock func() time.Time - newID func() (uuid.UUID, error) -} - -type IdentityServiceOption func(*IdentityService) - -func WithIdentityClock(clock func() time.Time) IdentityServiceOption { - return func(service *IdentityService) { - if clock != nil { - service.clock = clock - } - } -} - -func WithIdentityIDGenerator(generator func() (uuid.UUID, error)) IdentityServiceOption { - return func(service *IdentityService) { - if generator != nil { - service.newID = generator - } - } -} - -func NewIdentityService(store repository.IdentityStore, cfg config.Config, opts ...IdentityServiceOption) *IdentityService { - service := &IdentityService{store: store, cfg: cfg, clock: time.Now, newID: uuid.NewV7} - for _, option := range opts { - if option != nil { - option(service) - } - } - return service -} - -func (s *IdentityService) Resolve(ctx context.Context, external model.ExternalIdentity) (model.Principal, error) { - if strings.TrimSpace(external.Issuer) == "" || strings.TrimSpace(external.Subject) == "" || strings.TrimSpace(external.Username) == "" { - return model.Principal{}, ErrSubjectRequired - } - - user, err := s.store.GetUserByExternalIdentity(ctx, external.Issuer, external.Subject) - if err == nil { - if _, statusErr := principalForStatus(user); statusErr != nil { - return model.Principal{}, statusErr - } - profileUnchanged := user.Username == external.Username && user.Email == external.Email && user.DisplayName == external.DisplayName - recentlySeen := user.LastSeenAt != nil && s.clock().UTC().Sub(*user.LastSeenAt) < 15*time.Minute - if profileUnchanged && recentlySeen { - return model.Principal{User: user}, nil - } - user, err = s.store.UpsertUser(ctx, user.ID, external) - if err != nil { - return model.Principal{}, fmt.Errorf("refresh identity: %w", err) - } - return principalForStatus(user) - } - if !errors.Is(err, repository.ErrNotFound) { - return model.Principal{}, fmt.Errorf("resolve identity: %w", err) - } - if s.cfg.Registration.Mode == "disabled" { - return model.Principal{}, ErrRegistrationClosed - } - - id, err := s.newID() - if err != nil { - return model.Principal{}, fmt.Errorf("generate user ID: %w", err) - } - user, err = s.store.UpsertUser(ctx, id, external) - if err != nil { - return model.Principal{}, fmt.Errorf("create pending identity: %w", err) - } - return principalForStatus(user) -} - -func principalForStatus(user model.User) (model.Principal, error) { - switch user.Status { - case "pending", "active": - return model.Principal{User: user}, nil - case "suspended": - return model.Principal{}, ErrAccountSuspended - case "deleted": - return model.Principal{}, ErrAccountDeleted - default: - return model.Principal{}, fmt.Errorf("unknown account status %q", user.Status) - } -} - -func (s *IdentityService) Session(ctx context.Context, principal model.Principal) (model.Session, error) { - workspaces := []model.Workspace{} - if principal.User.Status == "active" { - var err error - workspaces, err = s.store.ListWorkspaces(ctx, principal.User.ID) - if err != nil { - return model.Session{}, fmt.Errorf("list session workspaces: %w", err) - } - } - return model.Session{ - User: principal.User, - Workspaces: workspaces, - Auth: model.SessionAuth{LogoutURL: strings.TrimRight(s.cfg.Identity.AuthURL, "/") + "/logout"}, - Registration: model.SessionRegistration{ - Mode: s.cfg.Registration.Mode, TermsVersion: s.cfg.Registration.TermsVersion, - PrivacyVersion: s.cfg.Registration.PrivacyVersion, - }, - }, nil -} - -func (s *IdentityService) Workspace(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) (model.Workspace, error) { - if principal.User.Status != "active" { - return model.Workspace{}, ErrOnboardingRequired - } - return s.store.GetWorkspaceMembership(ctx, workspaceID, principal.User.ID) -} - -func (s *IdentityService) AuthorizeWorkspace(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, roles ...string) (model.Workspace, error) { - workspace, err := s.Workspace(ctx, principal, workspaceID) - if err != nil { - return model.Workspace{}, err - } - if len(roles) == 0 { - return workspace, nil - } - for _, role := range roles { - if workspace.Role == role { - return workspace, nil - } - } - return model.Workspace{}, repository.ErrNotFound -} diff --git a/internal/service/identity_test.go b/internal/service/identity_test.go deleted file mode 100644 index db95d49..0000000 --- a/internal/service/identity_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package service - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository/memory" - "github.com/stretchr/testify/require" -) - -func TestPrincipalForAccountStatus(t *testing.T) { - for _, status := range []string{"pending", "active"} { - principal, err := principalForStatus(model.User{Status: status}) - require.NoError(t, err) - require.Equal(t, status, principal.User.Status) - } - - _, err := principalForStatus(model.User{Status: "suspended"}) - require.ErrorIs(t, err, ErrAccountSuspended) - _, err = principalForStatus(model.User{Status: "deleted"}) - require.ErrorIs(t, err, ErrAccountDeleted) - _, err = principalForStatus(model.User{Status: "unknown"}) - require.Error(t, err) - require.False(t, errors.Is(err, ErrOnboardingRequired)) -} - -func TestIdentityResolveCreatesPendingWithoutWorkspace(t *testing.T) { - clock := func() time.Time { return time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC) } - store := memory.New(memory.WithClock(clock)) - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg) - external := model.ExternalIdentity{ - Issuer: "https://github.com", Subject: "42", Username: "moon", DisplayName: "Moon User", - } - - first, err := identities.Resolve(context.Background(), external) - require.NoError(t, err) - second, err := identities.Resolve(context.Background(), external) - require.NoError(t, err) - require.Equal(t, first.User.ID, second.User.ID) - require.Equal(t, "pending", first.User.Status) - - session, err := identities.Session(context.Background(), second) - require.NoError(t, err) - require.Empty(t, session.Workspaces) -} - -func TestIdentityResolveKeepsStableIDAcrossProfileChanges(t *testing.T) { - store := memory.New() - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg) - first, err := identities.Resolve(t.Context(), model.ExternalIdentity{ - Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon", Email: "old@example.com", DisplayName: "Old Name", - }) - require.NoError(t, err) - updated, err := identities.Resolve(t.Context(), model.ExternalIdentity{ - Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon-renamed", Email: "new@example.com", DisplayName: "New Name", - }) - require.NoError(t, err) - require.Equal(t, first.User.ID, updated.User.ID) - require.Equal(t, "moon-renamed", updated.User.Username) - require.Equal(t, "new@example.com", updated.User.Email) - require.Equal(t, "New Name", updated.User.DisplayName) -} - -func TestIdentityResolveThrottlesLastSeenRefresh(t *testing.T) { - now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC) - clock := func() time.Time { return now } - store := memory.New(memory.WithClock(clock)) - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg, WithIdentityClock(clock)) - external := model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon"} - - first, err := identities.Resolve(t.Context(), external) - require.NoError(t, err) - require.NotNil(t, first.User.LastSeenAt) - initialLastSeen := *first.User.LastSeenAt - - now = now.Add(14 * time.Minute) - recent, err := identities.Resolve(t.Context(), external) - require.NoError(t, err) - require.Equal(t, initialLastSeen, *recent.User.LastSeenAt) - - now = now.Add(2 * time.Minute) - refreshed, err := identities.Resolve(t.Context(), external) - require.NoError(t, err) - require.Equal(t, now, *refreshed.User.LastSeenAt) -} - -func TestIdentityResolveNamespacesSubjectByIssuer(t *testing.T) { - store := memory.New() - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg) - github, err := identities.Resolve(t.Context(), model.ExternalIdentity{ - Issuer: "https://github.com", Subject: "42", Username: "github-user", - }) - require.NoError(t, err) - other, err := identities.Resolve(t.Context(), model.ExternalIdentity{ - Issuer: "https://identity.example.com", Subject: "42", Username: "other-user", - }) - require.NoError(t, err) - require.NotEqual(t, github.User.ID, other.User.ID) -} - -func TestRegistrationCompleteActivatesAndCreatesOnePersonalWorkspace(t *testing.T) { - store := memory.New() - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg) - registrations := NewRegistrationService(store, identities, cfg) - principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{ - Issuer: "https://github.com", Subject: "42", Username: "moon", Email: "moon@example.com", - }) - require.NoError(t, err) - - first, err := registrations.Complete(t.Context(), principal, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true}) - require.NoError(t, err) - second, err := registrations.Complete(t.Context(), model.Principal{User: first.User}, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true}) - require.NoError(t, err) - require.Equal(t, "active", first.User.Status) - require.Len(t, first.Workspaces, 1) - require.Len(t, second.Workspaces, 1) - require.Equal(t, first.Workspaces[0].ID, second.Workspaces[0].ID) -} - -func identityTestConfig(mode string) config.Config { - return config.Config{ - Identity: config.IdentityConfig{Issuer: "https://github.com", AuthURL: "https://auth.example.com"}, - Registration: config.RegistrationConfig{ - Mode: mode, TermsVersion: "terms-v1", PrivacyVersion: "privacy-v1", - InvitationTTL: 7 * 24 * time.Hour, CreatePersonalWorkspace: true, - }, - } -} diff --git a/internal/service/message.go b/internal/service/message.go deleted file mode 100644 index a009602..0000000 --- a/internal/service/message.go +++ /dev/null @@ -1,81 +0,0 @@ -package service - -import ( - "context" - "encoding/base64" - "errors" - "strings" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" -) - -type MessagePage struct { - Items []model.IMMessage `json:"items"` - NextCursor string `json:"nextCursor,omitempty"` -} -type MessageService struct { - store repository.ChannelStore - identities *IdentityService -} - -func NewMessageService(store repository.ChannelStore, identities *IdentityService) *MessageService { - return &MessageService{store: store, identities: identities} -} -func (s *MessageService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, channelID, conversationID *uuid.UUID, cursor string, pageSize int32) (MessagePage, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return MessagePage{}, err - } - if pageSize <= 0 || pageSize > 100 { - pageSize = 50 - } - decoded, err := decodeTimeCursor(cursor) - if err != nil { - return MessagePage{}, err - } - items, err := s.store.ListMessages(ctx, workspaceID, channelID, conversationID, decoded, pageSize+1) - if err != nil { - return MessagePage{}, err - } - page := MessagePage{Items: items} - if len(items) > int(pageSize) { - page.Items = items[:pageSize] - last := page.Items[len(page.Items)-1] - page.NextCursor = encodeTimeCursor(last.OccurredAt, last.ID) - } - return page, nil -} -func (s *MessageService) Conversations(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.IMConversation, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return nil, err - } - return s.store.ListConversations(ctx, workspaceID, 100) -} -func decodeTimeCursor(value string) (repository.TimeCursor, error) { - if value == "" { - return repository.MessageCursor{}, nil - } - decoded, err := base64.RawURLEncoding.DecodeString(value) - if err != nil { - return repository.MessageCursor{}, errors.New("invalid message cursor") - } - parts := strings.Split(string(decoded), "|") - if len(parts) != 2 { - return repository.MessageCursor{}, errors.New("invalid message cursor") - } - timestamp, err := time.Parse(time.RFC3339Nano, parts[0]) - if err != nil { - return repository.MessageCursor{}, errors.New("invalid message cursor") - } - id, err := uuid.Parse(parts[1]) - if err != nil { - return repository.MessageCursor{}, errors.New("invalid message cursor") - } - return repository.MessageCursor{BeforeTime: ×tamp, BeforeID: &id}, nil -} - -func encodeTimeCursor(timestamp time.Time, id uuid.UUID) string { - return base64.RawURLEncoding.EncodeToString([]byte(timestamp.Format(time.RFC3339Nano) + "|" + id.String())) -} diff --git a/internal/service/mutation.go b/internal/service/mutation.go deleted file mode 100644 index b788fa1..0000000 --- a/internal/service/mutation.go +++ /dev/null @@ -1,36 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "strings" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/repository" -) - -func newMutationRequest(workspaceID uuid.UUID, operation, resourceKey, idempotencyKey string, body any, resourceID uuid.UUID, activeFor, cooldown time.Duration, now time.Time) (repository.MutationRequest, error) { - encoded, err := json.Marshal(body) - if err != nil { - return repository.MutationRequest{}, err - } - digest := sha256.Sum256(encoded) - key := strings.TrimSpace(idempotencyKey) - if key == "" { - key = uuid.NewString() - } - return repository.MutationRequest{ - WorkspaceID: workspaceID, Operation: operation, ResourceKey: resourceKey, - IdempotencyKey: key, RequestHash: hex.EncodeToString(digest[:]), ResourceID: resourceID, - ActiveUntil: now.Add(activeFor), NextAllowedAt: now.Add(cooldown), - }, nil -} - -func finishMutation(store repository.MutationStore, request repository.MutationRequest, succeeded bool) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = store.FinishMutation(ctx, request, succeeded) -} diff --git a/internal/service/overview.go b/internal/service/overview.go deleted file mode 100644 index 067759e..0000000 --- a/internal/service/overview.go +++ /dev/null @@ -1,35 +0,0 @@ -package service - -import ( - "context" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" -) - -type OverviewService struct { - metadata repository.MetadataStore - channels repository.ChannelStore - identities *IdentityService -} - -func NewOverviewService(metadata repository.MetadataStore, channels repository.ChannelStore, identities *IdentityService) *OverviewService { - return &OverviewService{metadata: metadata, channels: channels, identities: identities} -} - -func (s *OverviewService) Get(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) (model.WorkspaceOverview, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return model.WorkspaceOverview{}, err - } - overview, err := s.metadata.GetWorkspaceOverview(ctx, workspaceID) - if err != nil { - return model.WorkspaceOverview{}, err - } - messages, err := s.channels.ListMessages(ctx, workspaceID, nil, nil, repository.MessageCursor{}, 5) - if err != nil { - return model.WorkspaceOverview{}, err - } - overview.RecentMessages = messages - return overview, nil -} diff --git a/internal/service/registration.go b/internal/service/registration.go deleted file mode 100644 index 49d2efe..0000000 --- a/internal/service/registration.go +++ /dev/null @@ -1,337 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "errors" - "fmt" - "io" - "net/mail" - "strings" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" -) - -var ( - ErrInvitationRequired = errors.New("a workspace invitation is required") - ErrAgreementsRequired = errors.New("terms and privacy policy must be accepted") -) - -type RegistrationService struct { - store repository.IdentityStore - identities *IdentityService - cfg config.Config - clock func() time.Time - newID func() (uuid.UUID, error) - random io.Reader -} - -type RegistrationServiceOption func(*RegistrationService) - -func WithRegistrationClock(clock func() time.Time) RegistrationServiceOption { - return func(service *RegistrationService) { - if clock != nil { - service.clock = clock - } - } -} - -func WithRegistrationIDGenerator(generator func() (uuid.UUID, error)) RegistrationServiceOption { - return func(service *RegistrationService) { - if generator != nil { - service.newID = generator - } - } -} - -func WithRegistrationRandom(reader io.Reader) RegistrationServiceOption { - return func(service *RegistrationService) { - if reader != nil { - service.random = reader - } - } -} - -func NewRegistrationService(store repository.IdentityStore, identities *IdentityService, cfg config.Config, opts ...RegistrationServiceOption) *RegistrationService { - service := &RegistrationService{store: store, identities: identities, cfg: cfg, clock: time.Now, newID: uuid.NewV7, random: rand.Reader} - for _, option := range opts { - if option != nil { - option(service) - } - } - return service -} - -type CompleteRegistrationInput struct { - AcceptTerms bool - AcceptPrivacy bool - RequestID string -} - -func (s *RegistrationService) Complete(ctx context.Context, principal model.Principal, input CompleteRegistrationInput) (model.Session, error) { - if s.cfg.Registration.Mode != "public" { - if s.cfg.Registration.Mode == "invite_only" { - return model.Session{}, ErrInvitationRequired - } - return model.Session{}, ErrRegistrationClosed - } - if !input.AcceptTerms || !input.AcceptPrivacy { - return model.Session{}, ErrAgreementsRequired - } - - user := principal.User - err := s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error { - locked, err := tx.GetUserForUpdate(ctx, principal.User.ID) - if err != nil { - return err - } - if locked.Status == "active" { - user = locked - return nil - } - if locked.Status != "pending" { - return accountStatusError(locked.Status) - } - user, err = tx.ActivateUser(ctx, locked.ID, s.cfg.Registration.TermsVersion, s.cfg.Registration.PrivacyVersion) - if err != nil { - return err - } - if s.cfg.Registration.CreatePersonalWorkspace { - if err := s.ensurePersonalWorkspace(ctx, tx, user); err != nil { - return err - } - } - return s.appendAccountAudit(ctx, tx, user.ID, "account.activate", input.RequestID, map[string]any{"registrationMode": "public"}) - }) - if err != nil { - return model.Session{}, fmt.Errorf("complete registration: %w", err) - } - return s.identities.Session(ctx, model.Principal{User: user}) -} - -type CreateInvitationInput struct { - Email string - Role string -} - -func (s *RegistrationService) CreateInvitation(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, input CreateInvitationInput) (model.WorkspaceInvitation, error) { - workspace, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin") - if err != nil { - return model.WorkspaceInvitation{}, err - } - email, err := normalizeEmail(input.Email) - if err != nil { - return model.WorkspaceInvitation{}, err - } - role := strings.ToLower(strings.TrimSpace(input.Role)) - if role != "admin" && role != "member" && role != "viewer" { - return model.WorkspaceInvitation{}, errors.New("invitation role must be admin, member, or viewer") - } - if workspace.Role != "owner" && role == "admin" { - return model.WorkspaceInvitation{}, ErrInsufficientRole - } - - tokenBytes := make([]byte, 32) - if _, err := io.ReadFull(s.random, tokenBytes); err != nil { - return model.WorkspaceInvitation{}, fmt.Errorf("generate invitation token: %w", err) - } - token := base64.RawURLEncoding.EncodeToString(tokenBytes) - digest := sha256.Sum256([]byte(token)) - id, err := s.newID() - if err != nil { - return model.WorkspaceInvitation{}, err - } - invitation := model.WorkspaceInvitation{ - ID: id, WorkspaceID: workspaceID, Email: email, Role: role, InvitedBy: principal.User.ID, - ExpiresAt: s.clock().UTC().Add(s.cfg.Registration.InvitationTTL), - } - err = s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error { - created, err := tx.CreateWorkspaceInvitation(ctx, invitation, digest[:]) - if err != nil { - return err - } - invitation = created - metadata := []byte(`{"role":"` + role + `"}`) - return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.invitation_create", "invitation", id, metadata) - }) - if err != nil { - return model.WorkspaceInvitation{}, err - } - invitation.Token = token - return invitation, nil -} - -func (s *RegistrationService) ListInvitations(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil { - return nil, err - } - return s.store.ListWorkspaceInvitations(ctx, workspaceID) -} - -func (s *RegistrationService) RevokeInvitation(ctx context.Context, principal model.Principal, workspaceID, invitationID uuid.UUID) error { - workspace, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin") - if err != nil { - return err - } - return s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error { - invitations, err := tx.ListWorkspaceInvitations(ctx, workspaceID) - if err != nil { - return err - } - var target *model.WorkspaceInvitation - for index := range invitations { - if invitations[index].ID == invitationID { - target = &invitations[index] - break - } - } - if target == nil { - return repository.ErrNotFound - } - if workspace.Role != "owner" && target.Role == "admin" { - return ErrInsufficientRole - } - if err := tx.RevokeWorkspaceInvitation(ctx, invitationID); err != nil { - return err - } - return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.invitation_revoke", "invitation", invitationID, []byte(`{}`)) - }) -} - -type AcceptInvitationInput struct { - Token string - AcceptTerms bool - AcceptPrivacy bool - RequestID string -} - -func (s *RegistrationService) AcceptInvitation(ctx context.Context, principal model.Principal, input AcceptInvitationInput) (model.Session, error) { - if !input.AcceptTerms || !input.AcceptPrivacy { - return model.Session{}, ErrAgreementsRequired - } - if s.cfg.Registration.Mode == "disabled" && principal.User.Status != "active" { - return model.Session{}, ErrRegistrationClosed - } - token := strings.TrimSpace(input.Token) - if token == "" || len(token) > 256 { - return model.Session{}, ErrInvitationInvalid - } - digest := sha256.Sum256([]byte(token)) - user := principal.User - err := s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error { - invitation, err := tx.GetWorkspaceInvitationForUpdate(ctx, digest[:]) - if err != nil { - return ErrInvitationInvalid - } - now := s.clock().UTC() - if invitation.AcceptedAt != nil || invitation.RevokedAt != nil || !now.Before(invitation.ExpiresAt) { - return ErrInvitationInvalid - } - email, err := normalizeEmail(principal.User.Email) - if err != nil || email != invitation.Email { - return ErrInvitationInvalid - } - locked, err := tx.GetUserForUpdate(ctx, principal.User.ID) - if err != nil { - return err - } - switch locked.Status { - case "pending": - user, err = tx.ActivateUser(ctx, locked.ID, s.cfg.Registration.TermsVersion, s.cfg.Registration.PrivacyVersion) - if err != nil { - return err - } - if s.cfg.Registration.CreatePersonalWorkspace { - if err := s.ensurePersonalWorkspace(ctx, tx, user); err != nil { - return err - } - } - case "active": - user = locked - default: - return accountStatusError(locked.Status) - } - if err := tx.AddWorkspaceMember(ctx, invitation.WorkspaceID, user.ID, invitation.Role); err != nil { - if errors.Is(err, repository.ErrConflict) { - return ErrInvitationInvalid - } - return err - } - if err := tx.AcceptWorkspaceInvitation(ctx, invitation.ID, user.ID); err != nil { - return ErrInvitationInvalid - } - if err := tx.AppendIdentityAudit(ctx, invitation.WorkspaceID, user.ID, "workspace.invitation_accept", "invitation", invitation.ID, []byte(`{}`)); err != nil { - return err - } - return s.appendAccountAudit(ctx, tx, user.ID, "account.invitation_accept", input.RequestID, map[string]any{"workspaceId": invitation.WorkspaceID.String()}) - }) - if err != nil { - if errors.Is(err, ErrInvitationInvalid) { - return model.Session{}, ErrInvitationInvalid - } - return model.Session{}, fmt.Errorf("accept invitation: %w", err) - } - return s.identities.Session(ctx, model.Principal{User: user}) -} - -func (s *RegistrationService) ensurePersonalWorkspace(ctx context.Context, tx repository.IdentityStore, user model.User) error { - if _, err := tx.GetPersonalWorkspace(ctx, user.ID); err == nil { - return nil - } else if !errors.Is(err, repository.ErrNotFound) { - return err - } - id, err := s.newID() - if err != nil { - return err - } - name := strings.TrimSpace(user.DisplayName) - if name == "" { - name = user.Username - } - workspace := model.Workspace{ - ID: id, Name: name + "'s workspace", Slug: "personal-" + strings.ReplaceAll(id.String(), "-", "")[:16], - Kind: "personal", CreatedBy: user.ID, Role: "owner", - } - if _, err := tx.CreateWorkspace(ctx, workspace); err != nil { - return err - } - return tx.AddWorkspaceMember(ctx, id, user.ID, "owner") -} - -func (s *RegistrationService) appendAccountAudit(ctx context.Context, tx repository.IdentityStore, userID uuid.UUID, action, requestID string, metadata map[string]any) error { - id, err := s.newID() - if err != nil { - return err - } - actor := userID - return tx.AppendAccountAudit(ctx, model.AccountAuditLog{ - ID: id, UserID: userID, ActorUserID: &actor, Action: action, Result: "success", - Provider: s.cfg.Identity.Issuer, RequestID: requestID, Metadata: metadata, OccurredAt: s.clock().UTC(), - }) -} - -func normalizeEmail(value string) (string, error) { - value = strings.TrimSpace(value) - parsed, err := mail.ParseAddress(value) - if err != nil || !strings.EqualFold(parsed.Address, value) { - return "", errors.New("a valid email address is required") - } - return strings.ToLower(parsed.Address), nil -} - -func accountStatusError(status string) error { - switch status { - case "suspended": - return ErrAccountSuspended - case "deleted": - return ErrAccountDeleted - default: - return ErrOnboardingRequired - } -} diff --git a/internal/service/registration_test.go b/internal/service/registration_test.go deleted file mode 100644 index bcb5efa..0000000 --- a/internal/service/registration_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package service - -import ( - "errors" - "sync" - "testing" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/repository/memory" - "github.com/stretchr/testify/require" -) - -func TestDisabledRegistrationDoesNotCreateUnknownUser(t *testing.T) { - store := memory.New() - disabled := identityTestConfig("disabled") - identities := NewIdentityService(store, disabled) - external := model.ExternalIdentity{Issuer: disabled.Identity.Issuer, Subject: "42", Username: "moon"} - - _, err := identities.Resolve(t.Context(), external) - require.ErrorIs(t, err, ErrRegistrationClosed) - _, err = store.GetUserByExternalIdentity(t.Context(), external.Issuer, external.Subject) - require.ErrorIs(t, err, repository.ErrNotFound) -} - -func TestDisabledRegistrationAllowsExistingActiveUser(t *testing.T) { - store := memory.New() - publicConfig := identityTestConfig("public") - publicIdentities := NewIdentityService(store, publicConfig) - publicRegistrations := NewRegistrationService(store, publicIdentities, publicConfig) - active := activateTestUser(t, publicIdentities, publicRegistrations, "42", "moon@example.com") - - disabledConfig := identityTestConfig("disabled") - disabledIdentities := NewIdentityService(store, disabledConfig) - resolved, err := disabledIdentities.Resolve(t.Context(), model.ExternalIdentity{ - Issuer: disabledConfig.Identity.Issuer, Subject: "42", Username: "moon", Email: "moon@example.com", - }) - require.NoError(t, err) - require.Equal(t, active.User.ID, resolved.User.ID) - require.Equal(t, "active", resolved.User.Status) -} - -func TestInviteOnlyRegistrationCannotCompleteWithoutInvitation(t *testing.T) { - store := memory.New() - cfg := identityTestConfig("invite_only") - identities := NewIdentityService(store, cfg) - registrations := NewRegistrationService(store, identities, cfg) - principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{ - Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon", Email: "moon@example.com", - }) - require.NoError(t, err) - - _, err = registrations.Complete(t.Context(), principal, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true}) - require.ErrorIs(t, err, ErrInvitationRequired) -} - -func TestDisabledRegistrationCannotActivateExistingPendingUser(t *testing.T) { - store := memory.New() - inviteConfig := identityTestConfig("invite_only") - inviteIdentities := NewIdentityService(store, inviteConfig) - pending, err := inviteIdentities.Resolve(t.Context(), model.ExternalIdentity{ - Issuer: inviteConfig.Identity.Issuer, Subject: "42", Username: "moon", Email: "moon@example.com", - }) - require.NoError(t, err) - require.Equal(t, "pending", pending.User.Status) - - disabledConfig := identityTestConfig("disabled") - disabledRegistrations := NewRegistrationService(store, NewIdentityService(store, disabledConfig), disabledConfig) - _, err = disabledRegistrations.AcceptInvitation(t.Context(), pending, AcceptInvitationInput{ - Token: "unused", AcceptTerms: true, AcceptPrivacy: true, - }) - require.ErrorIs(t, err, ErrRegistrationClosed) -} - -func TestInvitationRegistrationIsOneTimeAndEmailBound(t *testing.T) { - store := memory.New() - publicConfig := identityTestConfig("public") - publicIdentities := NewIdentityService(store, publicConfig) - publicRegistrations := NewRegistrationService(store, publicIdentities, publicConfig) - owner := activateTestUser(t, publicIdentities, publicRegistrations, "owner", "owner@example.com") - workspaces := NewWorkspaceService(store, publicIdentities) - workspace, err := workspaces.Create(t.Context(), owner, "Engineering") - require.NoError(t, err) - - inviteConfig := identityTestConfig("invite_only") - inviteIdentities := NewIdentityService(store, inviteConfig) - invitations := NewRegistrationService(store, inviteIdentities, inviteConfig) - created, err := invitations.CreateInvitation(t.Context(), owner, workspace.ID, CreateInvitationInput{Email: "Member@Example.com", Role: "member"}) - require.NoError(t, err) - require.NotEmpty(t, created.Token) - - listed, err := invitations.ListInvitations(t.Context(), owner, workspace.ID) - require.NoError(t, err) - require.Len(t, listed, 1) - require.Empty(t, listed[0].Token) - - wrongUser, err := inviteIdentities.Resolve(t.Context(), model.ExternalIdentity{Issuer: inviteConfig.Identity.Issuer, Subject: "wrong", Username: "wrong", Email: "wrong@example.com"}) - require.NoError(t, err) - _, err = invitations.AcceptInvitation(t.Context(), wrongUser, AcceptInvitationInput{Token: created.Token, AcceptTerms: true, AcceptPrivacy: true}) - require.ErrorIs(t, err, ErrInvitationInvalid) - - member, err := inviteIdentities.Resolve(t.Context(), model.ExternalIdentity{Issuer: inviteConfig.Identity.Issuer, Subject: "member", Username: "member", Email: "member@example.com"}) - require.NoError(t, err) - session, err := invitations.AcceptInvitation(t.Context(), member, AcceptInvitationInput{Token: created.Token, AcceptTerms: true, AcceptPrivacy: true}) - require.NoError(t, err) - require.Equal(t, "active", session.User.Status) - require.Len(t, session.Workspaces, 2) - - _, err = invitations.AcceptInvitation(t.Context(), model.Principal{User: session.User}, AcceptInvitationInput{Token: created.Token, AcceptTerms: true, AcceptPrivacy: true}) - require.ErrorIs(t, err, ErrInvitationInvalid) -} - -func TestInvitationRejectsExpiredAndRevokedTokens(t *testing.T) { - now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC) - clock := func() time.Time { return now } - store := memory.New(memory.WithClock(clock)) - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg, WithIdentityClock(clock)) - registrations := NewRegistrationService(store, identities, cfg, WithRegistrationClock(clock)) - owner := activateTestUser(t, identities, registrations, "owner", "owner@example.com") - member := activateTestUser(t, identities, registrations, "member", "member@example.com") - workspace, err := NewWorkspaceService(store, identities).Create(t.Context(), owner, "Engineering") - require.NoError(t, err) - - expired, err := registrations.CreateInvitation(t.Context(), owner, workspace.ID, CreateInvitationInput{Email: member.User.Email, Role: "member"}) - require.NoError(t, err) - now = now.Add(cfg.Registration.InvitationTTL) - _, err = registrations.AcceptInvitation(t.Context(), member, AcceptInvitationInput{Token: expired.Token, AcceptTerms: true, AcceptPrivacy: true}) - require.ErrorIs(t, err, ErrInvitationInvalid) - - now = now.Add(time.Minute) - revoked, err := registrations.CreateInvitation(t.Context(), owner, workspace.ID, CreateInvitationInput{Email: member.User.Email, Role: "member"}) - require.NoError(t, err) - require.NoError(t, registrations.RevokeInvitation(t.Context(), owner, workspace.ID, revoked.ID)) - _, err = registrations.AcceptInvitation(t.Context(), member, AcceptInvitationInput{Token: revoked.Token, AcceptTerms: true, AcceptPrivacy: true}) - require.ErrorIs(t, err, ErrInvitationInvalid) -} - -func TestInvitationHasExactlyOneConcurrentConsumer(t *testing.T) { - store := memory.New() - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg) - registrations := NewRegistrationService(store, identities, cfg) - owner := activateTestUser(t, identities, registrations, "owner", "owner@example.com") - member := activateTestUser(t, identities, registrations, "member", "member@example.com") - workspace, err := NewWorkspaceService(store, identities).Create(t.Context(), owner, "Engineering") - require.NoError(t, err) - invitation, err := registrations.CreateInvitation(t.Context(), owner, workspace.ID, CreateInvitationInput{Email: member.User.Email, Role: "member"}) - require.NoError(t, err) - - const attempts = 8 - results := make(chan error, attempts) - var group sync.WaitGroup - for range attempts { - group.Add(1) - go func() { - defer group.Done() - _, acceptErr := registrations.AcceptInvitation(t.Context(), member, AcceptInvitationInput{ - Token: invitation.Token, AcceptTerms: true, AcceptPrivacy: true, - }) - results <- acceptErr - }() - } - group.Wait() - close(results) - - succeeded := 0 - for result := range results { - if result == nil { - succeeded++ - continue - } - require.ErrorIs(t, result, ErrInvitationInvalid) - } - require.Equal(t, 1, succeeded) -} - -func TestAdminCannotGrantOrRevokeAdmin(t *testing.T) { - store := memory.New() - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg) - registrations := NewRegistrationService(store, identities, cfg) - workspaces := NewWorkspaceService(store, identities) - owner := activateTestUser(t, identities, registrations, "owner", "owner@example.com") - admin := activateTestUser(t, identities, registrations, "admin", "admin@example.com") - member := activateTestUser(t, identities, registrations, "member", "member@example.com") - workspace, err := workspaces.Create(t.Context(), owner, "Engineering") - require.NoError(t, err) - require.ErrorIs(t, workspaces.SetMember(t.Context(), owner, workspace.ID, admin.User.ID, "admin"), repository.ErrNotFound) - admin = inviteTestMember(t, registrations, owner, admin, workspace.ID, "admin") - member = inviteTestMember(t, registrations, owner, member, workspace.ID, "member") - - _, err = registrations.CreateInvitation(t.Context(), admin, workspace.ID, CreateInvitationInput{Email: "next@example.com", Role: "admin"}) - require.ErrorIs(t, err, ErrInsufficientRole) - require.ErrorIs(t, workspaces.SetMember(t.Context(), admin, workspace.ID, member.User.ID, "admin"), ErrInsufficientRole) - require.ErrorIs(t, workspaces.RemoveMember(t.Context(), admin, workspace.ID, admin.User.ID), ErrInsufficientRole) - require.NoError(t, workspaces.RemoveMember(t.Context(), admin, workspace.ID, member.User.ID)) -} - -func TestInvitationCannotReplaceExistingMembership(t *testing.T) { - store := memory.New() - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg) - registrations := NewRegistrationService(store, identities, cfg) - workspaces := NewWorkspaceService(store, identities) - owner := activateTestUser(t, identities, registrations, "owner", "owner@example.com") - admin := activateTestUser(t, identities, registrations, "admin", "admin@example.com") - workspace, err := workspaces.Create(t.Context(), owner, "Engineering") - require.NoError(t, err) - admin = inviteTestMember(t, registrations, owner, admin, workspace.ID, "admin") - - invitation, err := registrations.CreateInvitation(t.Context(), admin, workspace.ID, CreateInvitationInput{ - Email: owner.User.Email, - Role: "member", - }) - require.NoError(t, err) - - _, err = registrations.AcceptInvitation(t.Context(), owner, AcceptInvitationInput{ - Token: invitation.Token, - AcceptTerms: true, - AcceptPrivacy: true, - }) - require.ErrorIs(t, err, ErrInvitationInvalid) - - membership, err := identities.Workspace(t.Context(), owner, workspace.ID) - require.NoError(t, err) - require.Equal(t, "owner", membership.Role) -} - -func activateTestUser(t *testing.T, identities *IdentityService, registrations *RegistrationService, subject, email string) model.Principal { - t.Helper() - principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: identities.cfg.Identity.Issuer, Subject: subject, Username: subject, Email: email}) - require.NoError(t, err) - session, err := registrations.Complete(t.Context(), principal, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true}) - require.NoError(t, err) - return model.Principal{User: session.User} -} - -func inviteTestMember(t *testing.T, registrations *RegistrationService, inviter, invitee model.Principal, workspaceID uuid.UUID, role string) model.Principal { - t.Helper() - invitation, err := registrations.CreateInvitation(t.Context(), inviter, workspaceID, CreateInvitationInput{Email: invitee.User.Email, Role: role}) - require.NoError(t, err) - session, err := registrations.AcceptInvitation(t.Context(), invitee, AcceptInvitationInput{ - Token: invitation.Token, AcceptTerms: true, AcceptPrivacy: true, - }) - require.NoError(t, err) - return model.Principal{User: session.User} -} - -func TestPublicRegistrationRequiresAgreements(t *testing.T) { - store := memory.New() - cfg := identityTestConfig("public") - identities := NewIdentityService(store, cfg) - registrations := NewRegistrationService(store, identities, cfg) - principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon"}) - require.NoError(t, err) - - _, err = registrations.Complete(t.Context(), principal, CompleteRegistrationInput{}) - require.True(t, errors.Is(err, ErrAgreementsRequired)) -} diff --git a/internal/service/repository.go b/internal/service/repository.go deleted file mode 100644 index 7fa5167..0000000 --- a/internal/service/repository.go +++ /dev/null @@ -1,290 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/checkout" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/pkg/scm" -) - -type CreateRepositoryInput struct { - Name string - CloneURL string - ProviderType string - ConnectionID *uuid.UUID - Ref string - IdempotencyKey string -} - -type RepositoryService struct { - metadata repository.MetadataStore - registry *scm.Registry - identities *IdentityService - checkouts checkout.Store - clock func() time.Time - cooldown time.Duration -} - -type RepositoryServiceOption func(*RepositoryService) - -func WithRepositoryClock(clock func() time.Time) RepositoryServiceOption { - return func(service *RepositoryService) { - if clock != nil { - service.clock = clock - } - } -} -func WithRepositoryCooldown(cooldown time.Duration) RepositoryServiceOption { - return func(service *RepositoryService) { - if cooldown >= 0 { - service.cooldown = cooldown - } - } -} - -func NewRepositoryService(metadata repository.MetadataStore, registry *scm.Registry, identities *IdentityService, checkouts checkout.Store, cfg config.Config, opts ...RepositoryServiceOption) *RepositoryService { - service := &RepositoryService{metadata: metadata, registry: registry, identities: identities, checkouts: checkouts, clock: time.Now, cooldown: cfg.Git.SyncCooldown} - for _, option := range opts { - if option != nil { - option(service) - } - } - return service -} - -func (s *RepositoryService) Create(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, input CreateRepositoryInput) (repo model.Repository, job model.Job, resultErr error) { - resourceID := uuid.Nil - defer func() { - recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "repository.create", "repository", resourceID, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return model.Repository{}, model.Job{}, err - } - input.Name, input.CloneURL, input.ProviderType, input.Ref = strings.TrimSpace(input.Name), strings.TrimSpace(input.CloneURL), strings.ToLower(strings.TrimSpace(input.ProviderType)), strings.TrimSpace(input.Ref) - if input.Name == "" || input.CloneURL == "" { - return model.Repository{}, model.Job{}, errors.New("repository name and clone URL are required") - } - if err := validateRepositoryRef(input.Ref); err != nil { - return model.Repository{}, model.Job{}, err - } - providerType := input.ProviderType - if input.ConnectionID != nil { - connection, err := s.metadata.GetSCMConnection(ctx, workspaceID, *input.ConnectionID) - if err != nil { - return model.Repository{}, model.Job{}, err - } - providerType = connection.Type - } - if providerType == "" { - providerType = "generic" - } - provider, err := s.registry.Provider(providerType) - if err != nil { - return model.Repository{}, model.Job{}, err - } - remote, err := provider.Validate(ctx, input.CloneURL) - if err != nil { - return model.Repository{}, model.Job{}, fmt.Errorf("validate repository remote: %w", err) - } - repositoryID, err := uuid.NewV7() - if err != nil { - return model.Repository{}, model.Job{}, err - } - resourceID = repositoryID - jobID, err := uuid.NewV7() - if err != nil { - return model.Repository{}, model.Job{}, err - } - payload, _ := json.Marshal(model.RepositorySyncPayload{RepositoryID: repositoryID}) - now := s.clock().UTC() - repo = model.Repository{ID: repositoryID, WorkspaceID: workspaceID, ConnectionID: input.ConnectionID, Name: input.Name, CloneURL: remote.URL, NormalizedURL: remote.URL, Ref: input.Ref, State: "pending"} - job = model.Job{ID: jobID, WorkspaceID: workspaceID, Type: "repository.sync", Payload: payload, MaxAttempts: 5, RunAfter: now} - key := strings.TrimSpace(input.IdempotencyKey) - if key == "" { - key = "repository-create:" + repositoryID.String() - } - err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - var txErr error - repo, txErr = tx.CreateRepository(ctx, repo) - if txErr != nil { - return txErr - } - if txErr = tx.ReserveRepositorySync(ctx, workspaceID, repositoryID, now.Add(s.cooldown)); txErr != nil { - return txErr - } - job, txErr = tx.CreateJob(ctx, job, key) - if txErr != nil { - return txErr - } - metadata, _ := json.Marshal(map[string]string{"provider": providerType}) - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "repository.create", "repository", repositoryID, metadata) - }) - return repo, job, err -} - -func (s *RepositoryService) Sync(ctx context.Context, principal model.Principal, workspaceID, repositoryID uuid.UUID, ref *string, idempotencyKey string) (job model.Job, resultErr error) { - defer func() { - recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "repository.sync", "repository", repositoryID, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return model.Job{}, err - } - repo, err := s.metadata.GetRepository(ctx, workspaceID, repositoryID) - if err != nil { - return model.Job{}, err - } - targetRef := repo.Ref - if ref != nil { - targetRef = strings.TrimSpace(*ref) - } - if err := validateRepositoryRef(targetRef); err != nil { - return model.Job{}, err - } - jobID, err := uuid.NewV7() - if err != nil { - return model.Job{}, err - } - payload, _ := json.Marshal(model.RepositorySyncPayload{RepositoryID: repositoryID}) - now := s.clock().UTC() - job = model.Job{ID: jobID, WorkspaceID: workspaceID, Type: "repository.sync", Payload: payload, MaxAttempts: 5, RunAfter: now} - key := strings.TrimSpace(idempotencyKey) - if key == "" { - key = "repository-sync:" + jobID.String() - } - err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - if targetRef != repo.Ref { - if err := tx.SetRepositoryRef(ctx, workspaceID, repositoryID, targetRef); err != nil { - return err - } - } - if err := tx.ReserveRepositorySync(ctx, workspaceID, repositoryID, now.Add(s.cooldown)); err != nil { - return err - } - var txErr error - job, txErr = tx.CreateJob(ctx, job, key) - if txErr != nil { - return txErr - } - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "repository.sync", "repository", repositoryID, []byte(`{}`)) - }) - return job, err -} - -func validateRepositoryRef(ref string) error { - if len(ref) > 512 || strings.ContainsAny(ref, "\x00\r\n") { - return errors.New("repository ref is invalid") - } - return nil -} - -func (s *RepositoryService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.Repository, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return nil, err - } - return s.metadata.ListRepositories(ctx, workspaceID) -} -func (s *RepositoryService) Get(ctx context.Context, principal model.Principal, workspaceID, repositoryID uuid.UUID) (model.Repository, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return model.Repository{}, err - } - return s.metadata.GetRepository(ctx, workspaceID, repositoryID) -} -func (s *RepositoryService) Job(ctx context.Context, principal model.Principal, workspaceID, jobID uuid.UUID) (model.Job, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return model.Job{}, err - } - return s.metadata.GetJob(ctx, workspaceID, jobID) -} - -func (s *RepositoryService) CancelJob(ctx context.Context, principal model.Principal, workspaceID, jobID uuid.UUID) (cancelled model.Job, resultErr error) { - defer func() { - recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "job.cancel", "job", jobID, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return model.Job{}, err - } - existing, err := s.metadata.GetJob(ctx, workspaceID, jobID) - if err != nil { - return model.Job{}, err - } - if existing.Status != "queued" && existing.Status != "running" { - return model.Job{}, repository.ErrConflict - } - err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - var txErr error - cancelled, txErr = tx.CancelJob(ctx, workspaceID, jobID) - if txErr != nil { - return txErr - } - if cancelled.Type == "repository.sync" { - var payload model.RepositorySyncPayload - if json.Unmarshal(cancelled.Payload, &payload) == nil && payload.RepositoryID != uuid.Nil { - if txErr = tx.MarkRepositoryFailed(ctx, workspaceID, payload.RepositoryID, "job.cancelled", "Synchronization was cancelled"); txErr != nil { - return txErr - } - } - } - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "job.cancel", "job", jobID, []byte(`{}`)) - }) - return cancelled, err -} - -func (s *RepositoryService) CancelSync(ctx context.Context, principal model.Principal, workspaceID, repositoryID uuid.UUID) (cancelled model.Job, resultErr error) { - defer func() { - recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "repository.sync_cancel", "repository", repositoryID, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return model.Job{}, err - } - if _, err := s.metadata.GetRepository(ctx, workspaceID, repositoryID); err != nil { - return model.Job{}, err - } - err := s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - var txErr error - cancelled, txErr = tx.CancelActiveRepositorySyncJob(ctx, workspaceID, repositoryID) - if txErr != nil { - if errors.Is(txErr, repository.ErrNotFound) { - return repository.ErrConflict - } - return txErr - } - if txErr = tx.MarkRepositoryFailed(ctx, workspaceID, repositoryID, "job.cancelled", "Synchronization was cancelled"); txErr != nil { - return txErr - } - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "repository.sync_cancel", "repository", repositoryID, []byte(`{}`)) - }) - return cancelled, err -} - -func (s *RepositoryService) Delete(ctx context.Context, principal model.Principal, workspaceID, repositoryID uuid.UUID) (resultErr error) { - defer func() { - recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "repository.delete", "repository", repositoryID, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil { - return err - } - if _, err := s.metadata.GetRepository(ctx, workspaceID, repositoryID); err != nil { - return err - } - if err := s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - if err := tx.CancelRepositorySyncJobs(ctx, workspaceID, repositoryID); err != nil { - return err - } - if _, err := tx.SoftDeleteRepository(ctx, workspaceID, repositoryID); err != nil { - return err - } - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "repository.delete", "repository", repositoryID, []byte(`{}`)) - }); err != nil { - return err - } - return s.checkouts.Remove(ctx, repositoryID) -} diff --git a/internal/service/scm_connection.go b/internal/service/scm_connection.go deleted file mode 100644 index 6598abd..0000000 --- a/internal/service/scm_connection.go +++ /dev/null @@ -1,329 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/url" - "strings" - "time" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/config" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" - "github.com/mooncode-ai/mooncode/internal/secretstore" - gitcore "github.com/mooncode-ai/mooncode/pkg/git" - "github.com/mooncode-ai/mooncode/pkg/scm" -) - -type CreateSCMConnectionInput struct { - Type string - Name string - BaseURL string - AuthType string - Secrets map[string]string -} - -type UpdateSCMConnectionInput struct { - Name *string - BaseURL *string - AuthType *string - Secrets map[string]string - ClearSecrets bool -} - -type SCMConnectionService struct { - metadata repository.MetadataStore - mutations repository.MutationStore - secrets secretstore.SecretStore - registry *scm.Registry - identities *IdentityService - git gitcore.Client - clock func() time.Time - gitConfig config.GitConfig -} - -type SCMConnectionServiceOption func(*SCMConnectionService) - -func WithSCMConnectionClock(clock func() time.Time) SCMConnectionServiceOption { - return func(service *SCMConnectionService) { - if clock != nil { - service.clock = clock - } - } -} - -func NewSCMConnectionService(metadata repository.MetadataStore, mutations repository.MutationStore, secrets secretstore.SecretStore, registry *scm.Registry, identities *IdentityService, client gitcore.Client, cfg config.Config, opts ...SCMConnectionServiceOption) *SCMConnectionService { - service := &SCMConnectionService{metadata: metadata, mutations: mutations, secrets: secrets, registry: registry, identities: identities, git: client, clock: time.Now, gitConfig: cfg.Git} - for _, option := range opts { - if option != nil { - option(service) - } - } - return service -} - -func (s *SCMConnectionService) Create(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, input CreateSCMConnectionInput) (connection model.SCMConnection, resultErr error) { - resourceID := uuid.Nil - defer func() { - recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "scm_connection.create", "scm_connection", resourceID, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return model.SCMConnection{}, err - } - input.Type, input.Name, input.BaseURL, input.AuthType = strings.ToLower(strings.TrimSpace(input.Type)), strings.TrimSpace(input.Name), strings.TrimSpace(input.BaseURL), strings.ToLower(strings.TrimSpace(input.AuthType)) - if err := validateSCMConnectionFields(input.Name, input.BaseURL, input.AuthType, input.Secrets); err != nil { - return model.SCMConnection{}, err - } - provider, err := s.registry.Provider(input.Type) - if err != nil { - return model.SCMConnection{}, err - } - if _, err := provider.Validate(ctx, input.BaseURL+"/mooncode/validation.git"); err != nil { - // Base URLs have no repository path; validate the actual origin by adding - // a fixed non-sensitive path through the same policy. - return model.SCMConnection{}, fmt.Errorf("validate SCM base URL: %w", err) - } - id, err := uuid.NewV7() - if err != nil { - return model.SCMConnection{}, err - } - resourceID = id - connection = model.SCMConnection{ID: id, WorkspaceID: workspaceID, Type: input.Type, Name: input.Name, BaseURL: strings.TrimSuffix(input.BaseURL, "/"), AuthType: input.AuthType} - var ref secretstore.SecretRef - if len(input.Secrets) > 0 { - values := make(secretstore.SecretValues, len(input.Secrets)) - for key, value := range input.Secrets { - values[key] = secretstore.NewSecretString(value) - } - ref, err = s.secrets.Put(ctx, secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: id}, values) - if err != nil { - return model.SCMConnection{}, err - } - connection.SecretRef = &ref.ID - } - err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - var txErr error - connection, txErr = tx.CreateSCMConnection(ctx, connection) - if txErr != nil { - return txErr - } - metadata, _ := json.Marshal(map[string]string{"type": connection.Type, "authType": connection.AuthType}) - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "scm_connection.create", "scm_connection", id, metadata) - }) - if err != nil { - if ref.ID != uuid.Nil { - _ = s.secrets.Delete(ctx, ref) - } - return model.SCMConnection{}, err - } - return connection, nil -} - -func (s *SCMConnectionService) Update(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, input UpdateSCMConnectionInput) (connection model.SCMConnection, resultErr error) { - defer func() { - recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "scm_connection.update", "scm_connection", id, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return model.SCMConnection{}, err - } - connection, err := s.metadata.GetSCMConnection(ctx, workspaceID, id) - if err != nil { - return model.SCMConnection{}, err - } - if input.Name != nil { - connection.Name = strings.TrimSpace(*input.Name) - } - if input.BaseURL != nil { - connection.BaseURL = strings.TrimSuffix(strings.TrimSpace(*input.BaseURL), "/") - } - if input.AuthType != nil { - connection.AuthType = strings.ToLower(strings.TrimSpace(*input.AuthType)) - } - provider, err := s.registry.Provider(connection.Type) - if err != nil { - return model.SCMConnection{}, err - } - if _, err := provider.Validate(ctx, connection.BaseURL+"/mooncode/validation.git"); err != nil { - return model.SCMConnection{}, fmt.Errorf("validate SCM base URL: %w", err) - } - currentSecrets := map[string]string{} - oldRef := secretstore.SecretRef{} - if connection.SecretRef != nil { - oldRef = secretstore.SecretRef{ID: *connection.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: id}} - if !input.ClearSecrets && input.Secrets != nil { - values, getErr := s.secrets.Get(ctx, oldRef) - if getErr != nil { - return model.SCMConnection{}, getErr - } - for key, value := range values { - currentSecrets[key] = value.Reveal() - } - } - } - for key, value := range input.Secrets { - currentSecrets[key] = value - } - if err := validateSCMConnectionFields(connection.Name, connection.BaseURL, connection.AuthType, currentSecrets); err != nil { - return model.SCMConnection{}, err - } - secretsChanged := input.ClearSecrets || input.Secrets != nil - newRef := secretstore.SecretRef{} - if secretsChanged { - if len(currentSecrets) > 0 { - newRef, err = s.secrets.Put(ctx, secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: id}, toSecretValues(currentSecrets)) - if err != nil { - return model.SCMConnection{}, err - } - connection.SecretRef = &newRef.ID - } else { - connection.SecretRef = nil - } - } - err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - var txErr error - connection, txErr = tx.UpdateSCMConnection(ctx, connection) - if txErr != nil { - return txErr - } - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "scm_connection.update", "scm_connection", id, []byte(`{}`)) - }) - if err != nil { - if newRef.ID != uuid.Nil { - _ = s.secrets.Delete(ctx, newRef) - } - return model.SCMConnection{}, err - } - if secretsChanged && oldRef.ID != uuid.Nil { - _ = s.secrets.Delete(ctx, oldRef) - } - return connection, nil -} - -func (s *SCMConnectionService) Test(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, remoteURL, idempotencyKey string) (resultErr error) { - defer func() { - recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "scm_connection.test", "scm_connection", id, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil { - return err - } - connection, err := s.metadata.GetSCMConnection(ctx, workspaceID, id) - if err != nil { - return err - } - provider, err := s.registry.Provider(connection.Type) - if err != nil { - return err - } - remote, err := provider.Validate(ctx, strings.TrimSpace(remoteURL)) - if err != nil { - return err - } - base, err := url.Parse(connection.BaseURL) - if err != nil || !strings.EqualFold(base.Hostname(), remote.Host) || !strings.HasPrefix(remote.Path, strings.TrimSuffix(base.Path, "/")+"/") { - return errors.New("probe repository must belong to the SCM connection origin") - } - credential := gitcore.Credential{} - if connection.SecretRef != nil { - ref := secretstore.SecretRef{ID: *connection.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: id}} - values, getErr := s.secrets.Get(ctx, ref) - if getErr != nil { - return getErr - } - credential = gitcore.NewCredential(revealSecret(values, "username"), revealSecret(values, "token")) - } - mutation, err := newMutationRequest(workspaceID, "scm_connection.test", id.String(), idempotencyKey, map[string]string{"repositoryUrl": remote.URL}, id, s.gitConfig.CloneTimeout, s.gitConfig.ProbeCooldown, s.clock().UTC()) - if err != nil { - return err - } - claim, err := s.mutations.BeginMutation(ctx, mutation) - if err != nil { - return err - } - if claim.Replay { - return nil - } - succeeded := false - defer func() { finishMutation(s.mutations, mutation, succeeded) }() - if err := s.git.Probe(ctx, gitcore.ProbeRequest{Provider: connection.Type, Remote: remote.URL, Credential: credential}); err != nil { - return err - } - if err := s.metadata.AppendAudit(ctx, workspaceID, principal.User.ID, "scm_connection.test", "scm_connection", id, []byte(`{}`)); err != nil { - return err - } - succeeded = true - return nil -} - -func validateSCMConnectionFields(name, baseURL, authType string, secrets map[string]string) error { - if strings.TrimSpace(name) == "" || strings.TrimSpace(baseURL) == "" { - return errors.New("connection name and base URL are required") - } - switch strings.ToLower(strings.TrimSpace(authType)) { - case "none", "token", "basic": - default: - return errors.New("connection auth type must be none, token, or basic") - } - for key := range secrets { - if key != "username" && key != "token" { - return fmt.Errorf("unsupported connection secret field %q", key) - } - } - if len(secrets) > 0 && strings.TrimSpace(secrets["token"]) == "" { - return errors.New("connection token is required when credentials are configured") - } - return nil -} - -func toSecretValues(values map[string]string) secretstore.SecretValues { - result := make(secretstore.SecretValues, len(values)) - for key, value := range values { - result[key] = secretstore.NewSecretString(value) - } - return result -} - -func revealSecret(values secretstore.SecretValues, key string) string { - if value, ok := values[key]; ok { - return value.Reveal() - } - return "" -} - -func (s *SCMConnectionService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.SCMConnection, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return nil, err - } - return s.metadata.ListSCMConnections(ctx, workspaceID) -} - -func (s *SCMConnectionService) Delete(ctx context.Context, principal model.Principal, workspaceID, connectionID uuid.UUID) (resultErr error) { - defer func() { - recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "scm_connection.delete", "scm_connection", connectionID, resultErr) - }() - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil { - return err - } - connection, err := s.metadata.GetSCMConnection(ctx, workspaceID, connectionID) - if err != nil { - return err - } - err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error { - if _, deleteErr := tx.SoftDeleteSCMConnection(ctx, workspaceID, connectionID); deleteErr != nil { - if errors.Is(deleteErr, repository.ErrNotFound) { - return repository.ErrConflict - } - return deleteErr - } - return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "scm_connection.delete", "scm_connection", connectionID, []byte(`{}`)) - }) - if err != nil { - return err - } - if connection.SecretRef != nil { - _ = s.secrets.Delete(ctx, secretstore.SecretRef{ID: *connection.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: connectionID}}) - } - return nil -} diff --git a/internal/service/workspace.go b/internal/service/workspace.go deleted file mode 100644 index c797c19..0000000 --- a/internal/service/workspace.go +++ /dev/null @@ -1,170 +0,0 @@ -package service - -import ( - "context" - "errors" - "strings" - "unicode" - - "github.com/google/uuid" - "github.com/mooncode-ai/mooncode/internal/model" - "github.com/mooncode-ai/mooncode/internal/repository" -) - -type WorkspaceService struct { - store repository.IdentityStore - identities *IdentityService -} - -type WorkspaceServiceOption func(*WorkspaceService) - -func NewWorkspaceService(store repository.IdentityStore, identities *IdentityService, opts ...WorkspaceServiceOption) *WorkspaceService { - service := &WorkspaceService{store: store, identities: identities} - for _, option := range opts { - if option != nil { - option(service) - } - } - return service -} - -func (s *WorkspaceService) Create(ctx context.Context, principal model.Principal, name string) (model.Workspace, error) { - name = strings.TrimSpace(name) - if name == "" || len(name) > 120 { - return model.Workspace{}, errors.New("workspace name is required and must not exceed 120 bytes") - } - id, err := uuid.NewV7() - if err != nil { - return model.Workspace{}, err - } - workspace := model.Workspace{ID: id, Name: name, Slug: workspaceSlug(name, id), Kind: "team", CreatedBy: principal.User.ID, Role: "owner"} - err = s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error { - var txErr error - workspace, txErr = tx.CreateWorkspace(ctx, workspace) - if txErr != nil { - return txErr - } - workspace.Role = "owner" - if txErr = tx.AddWorkspaceMember(ctx, id, principal.User.ID, "owner"); txErr != nil { - return txErr - } - return tx.AppendIdentityAudit(ctx, id, principal.User.ID, "workspace.create", "workspace", id, []byte(`{"kind":"team"}`)) - }) - return workspace, err -} - -func (s *WorkspaceService) Update(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, name string) (workspace model.Workspace, resultErr error) { - defer func() { - recordFailure(ctx, s.store, workspaceID, principal.User.ID, "workspace.update", "workspace", workspaceID, resultErr) - }() - authorized, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin") - if err != nil { - return model.Workspace{}, err - } - name = strings.TrimSpace(name) - if name == "" || len(name) > 120 { - return model.Workspace{}, errors.New("workspace name is required and must not exceed 120 bytes") - } - err = s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error { - var txErr error - workspace, txErr = tx.UpdateWorkspace(ctx, workspaceID, name) - if txErr != nil { - return txErr - } - return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.update", "workspace", workspaceID, []byte(`{}`)) - }) - workspace.Role = authorized.Role - return workspace, err -} - -func (s *WorkspaceService) Members(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) { - if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil { - return nil, err - } - return s.store.ListWorkspaceMembers(ctx, workspaceID) -} - -func (s *WorkspaceService) SetMember(ctx context.Context, principal model.Principal, workspaceID, userID uuid.UUID, role string) (resultErr error) { - defer func() { - recordFailure(ctx, s.store, workspaceID, principal.User.ID, "workspace.member_set", "user", userID, resultErr) - }() - workspace, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin") - if err != nil { - return err - } - role = strings.ToLower(strings.TrimSpace(role)) - if !validWorkspaceRole(role) { - return errors.New("workspace role must be owner, admin, member, or viewer") - } - if role == "owner" { - return errors.New("workspace ownership must be transferred through the ownership workflow") - } - if userID == workspace.CreatedBy && role != "owner" { - return errors.New("workspace creator must remain an owner") - } - target, targetErr := s.store.GetWorkspaceMembership(ctx, workspaceID, userID) - if targetErr != nil { - return targetErr - } - if workspace.Role != "owner" && (role == "admin" || target.Role == "owner" || target.Role == "admin") { - return ErrInsufficientRole - } - return s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error { - if err := tx.UpsertWorkspaceMember(ctx, workspaceID, userID, role); err != nil { - return err - } - return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.member_set", "user", userID, []byte(`{"role":"`+role+`"}`)) - }) -} - -func (s *WorkspaceService) RemoveMember(ctx context.Context, principal model.Principal, workspaceID, userID uuid.UUID) (resultErr error) { - defer func() { - recordFailure(ctx, s.store, workspaceID, principal.User.ID, "workspace.member_remove", "user", userID, resultErr) - }() - workspace, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin") - if err != nil { - return err - } - if userID == workspace.CreatedBy { - return errors.New("workspace creator cannot be removed") - } - target, targetErr := s.store.GetWorkspaceMembership(ctx, workspaceID, userID) - if targetErr != nil { - return targetErr - } - if workspace.Role != "owner" && (target.Role == "owner" || target.Role == "admin") { - return ErrInsufficientRole - } - return s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error { - if err := tx.DeleteWorkspaceMember(ctx, workspaceID, userID); err != nil { - return err - } - return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.member_remove", "user", userID, []byte(`{}`)) - }) -} - -func validWorkspaceRole(role string) bool { - return role == "owner" || role == "admin" || role == "member" || role == "viewer" -} - -func workspaceSlug(name string, id uuid.UUID) string { - var builder strings.Builder - lastHyphen := false - for _, value := range strings.ToLower(name) { - if unicode.IsLetter(value) || unicode.IsDigit(value) { - builder.WriteRune(value) - lastHyphen = false - } else if builder.Len() > 0 && !lastHyphen { - builder.WriteByte('-') - lastHyphen = true - } - if builder.Len() >= 40 { - break - } - } - prefix := strings.Trim(builder.String(), "-") - if prefix == "" { - prefix = "workspace" - } - return prefix + "-" + strings.ReplaceAll(id.String(), "-", "")[:8] -} diff --git a/internal/testutil/testdb/testdb.go b/internal/testutil/testdb/testdb.go new file mode 100644 index 0000000..5dc9abe --- /dev/null +++ b/internal/testutil/testdb/testdb.go @@ -0,0 +1,67 @@ +//go:build integration + +// Package testdb provides isolated PostgreSQL databases for integration tests. +package testdb + +import ( + "context" + "testing" + + "github.com/fuchencong/mooncode/internal/platform/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Open creates a schema dedicated to the current test, migrates it, and +// removes it when the test completes. +func Open(t testing.TB, databaseURL string) *pgxpool.Pool { + t.Helper() + + ctx := context.Background() + admin, err := database.Open(ctx, databaseURL, 2) + if err != nil { + t.Fatal(err) + } + + schema := "test_" + uuid.NewString() + identifier := pgx.Identifier{schema}.Sanitize() + if _, err := admin.Exec(ctx, "CREATE SCHEMA "+identifier); err != nil { + admin.Close() + t.Fatal(err) + } + + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + admin.Close() + t.Fatal(err) + } + config.MaxConns = 5 + config.ConnConfig.RuntimeParams["search_path"] = identifier + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + admin.Close() + t.Fatal(err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + admin.Close() + t.Fatal(err) + } + if err := database.Migrate(ctx, pool); err != nil { + pool.Close() + admin.Close() + t.Fatal(err) + } + + t.Cleanup(func() { + pool.Close() + if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+identifier+" CASCADE"); err != nil { + t.Errorf("drop test schema: %v", err) + } + admin.Close() + }) + + return pool +} diff --git a/internal/version/version.go b/internal/version/version.go deleted file mode 100644 index 30c3516..0000000 --- a/internal/version/version.go +++ /dev/null @@ -1,22 +0,0 @@ -package version - -// These values are populated with -ldflags in release builds. -var ( - Version = "dev" - Commit = "none" - Date = "unknown" -) - -type Info struct { - Version string - Commit string - Date string -} - -func Current() Info { - return Info{ - Version: Version, - Commit: Commit, - Date: Date, - } -} diff --git a/internal/workflow/biz/failure.go b/internal/workflow/biz/failure.go new file mode 100644 index 0000000..67e79ed --- /dev/null +++ b/internal/workflow/biz/failure.go @@ -0,0 +1,29 @@ +package biz + +import "errors" + +type permanentError struct { + cause error +} + +func Permanent(cause error) error { + if cause == nil || IsPermanent(cause) { + return cause + } + + return &permanentError{cause: cause} +} + +func (e *permanentError) Error() string { + return e.cause.Error() +} + +func (e *permanentError) Unwrap() error { + return e.cause +} + +func IsPermanent(err error) bool { + var target *permanentError + + return errors.As(err, &target) +} diff --git a/internal/workflow/biz/failure_test.go b/internal/workflow/biz/failure_test.go new file mode 100644 index 0000000..e4a5b32 --- /dev/null +++ b/internal/workflow/biz/failure_test.go @@ -0,0 +1,17 @@ +package biz + +import ( + "errors" + "testing" +) + +func TestPermanentPreservesCause(t *testing.T) { + cause := errors.New("invalid input") + err := Permanent(cause) + if !IsPermanent(err) || !errors.Is(err, cause) { + t.Fatalf("permanent error did not preserve cause: %v", err) + } + if Permanent(err) != err { + t.Fatal("permanent error was wrapped more than once") + } +} diff --git a/internal/workflow/biz/model.go b/internal/workflow/biz/model.go new file mode 100644 index 0000000..8c2cae5 --- /dev/null +++ b/internal/workflow/biz/model.go @@ -0,0 +1,55 @@ +package biz + +import ( + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" +) + +var ErrDispatchNotPending = errors.New("workflow dispatch is no longer pending") + +const ( + AggregateRepositoryOperation = "repository_operation" + AggregateAnalysisRun = "analysis_run" + AggregateNotification = "notification" + AggregateRetentionCleanup = "retention_cleanup" + + WorkflowRepositoryOperation = "repository-operation" + WorkflowAnalysisRun = "analysis-run" + WorkflowNotificationDelivery = "notification-delivery" + WorkflowRetentionCleanup = "retention-cleanup" +) + +type Payload struct { + AggregateID uuid.UUID `json:"aggregateId"` + RepositoryID uuid.UUID `json:"repositoryId"` + ChannelID uuid.UUID `json:"channelId,omitempty"` + WorkspaceID uuid.UUID `json:"workspaceId,omitempty"` +} + +func NewDispatch(aggregateType string, aggregateID uuid.UUID, workflowName string, payload Payload) (Dispatch, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return Dispatch{}, err + } + + return Dispatch{ + ID: uuid.New(), + AggregateType: aggregateType, + AggregateID: aggregateID, + WorkflowName: workflowName, + Payload: encoded, + }, nil +} + +type Dispatch struct { + ID uuid.UUID + AggregateType string + AggregateID uuid.UUID + WorkflowName string + Payload json.RawMessage + Attempts int32 + AvailableAt time.Time +} diff --git a/internal/workflow/data/store.go b/internal/workflow/data/store.go new file mode 100644 index 0000000..0826812 --- /dev/null +++ b/internal/workflow/data/store.go @@ -0,0 +1,97 @@ +package data + +import ( + "context" + "fmt" + "time" + + "github.com/fuchencong/mooncode/internal/data/sqlc" + workflow "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool + queries *sqlc.Queries +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool, queries: sqlc.New(pool)} +} + +func (s *Store) Claim(ctx context.Context, limit int32, lease time.Duration) ([]workflow.Dispatch, error) { + rows, err := s.queries.ClaimWorkflowDispatches(ctx, sqlc.ClaimWorkflowDispatchesParams{ + LeaseDuration: pgtype.Interval{Microseconds: lease.Microseconds(), Valid: true}, + Limit: limit, + }) + if err != nil { + return nil, err + } + + items := make([]workflow.Dispatch, 0, len(rows)) + for _, row := range rows { + items = append(items, workflow.Dispatch{ + ID: row.ID, + AggregateType: row.AggregateType, + AggregateID: row.AggregateID, + WorkflowName: row.WorkflowName, + Payload: row.Payload, + Attempts: row.Attempts, + AvailableAt: row.AvailableAt.Time, + }) + } + + return items, nil +} + +func (s *Store) IsPending(ctx context.Context, id uuid.UUID) (bool, error) { + return s.queries.IsWorkflowDispatchPending(ctx, id) +} + +func (s *Store) Complete(ctx context.Context, dispatch workflow.Dispatch, workflowRunID string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.queries.WithTx(tx) + + switch dispatch.AggregateType { + case workflow.AggregateRepositoryOperation: + err = q.SetRepositoryOperationWorkflowID(ctx, sqlc.SetRepositoryOperationWorkflowIDParams{ID: dispatch.AggregateID, WorkflowRunID: text(workflowRunID)}) + case workflow.AggregateAnalysisRun: + err = q.SetAnalysisWorkflowID(ctx, sqlc.SetAnalysisWorkflowIDParams{ID: dispatch.AggregateID, WorkflowRunID: text(workflowRunID)}) + case workflow.AggregateNotification: + err = q.SetNotificationWorkflowID(ctx, sqlc.SetNotificationWorkflowIDParams{ID: dispatch.AggregateID, WorkflowRunID: text(workflowRunID)}) + case workflow.AggregateRetentionCleanup: + err = q.SetRetentionCleanupWorkflowID(ctx, sqlc.SetRetentionCleanupWorkflowIDParams{ID: dispatch.AggregateID, WorkflowRunID: text(workflowRunID)}) + default: + return fmt.Errorf("unsupported workflow aggregate type %q", dispatch.AggregateType) + } + if err != nil { + return err + } + updated, err := q.MarkWorkflowDispatched(ctx, dispatch.ID) + if err != nil { + return err + } + if updated != 1 { + return workflow.ErrDispatchNotPending + } + + return tx.Commit(ctx) +} + +func (s *Store) Delay(ctx context.Context, id uuid.UUID, availableAt time.Time, message string) error { + return s.queries.DelayWorkflowDispatch(ctx, sqlc.DelayWorkflowDispatchParams{ + ID: id, + AvailableAt: pgtype.Timestamptz{Time: availableAt, Valid: true}, + LastError: text(message), + }) +} + +func text(value string) pgtype.Text { + return pgtype.Text{String: value, Valid: value != ""} +} diff --git a/internal/workflow/data/store_integration_test.go b/internal/workflow/data/store_integration_test.go new file mode 100644 index 0000000..e31ff73 --- /dev/null +++ b/internal/workflow/data/store_integration_test.go @@ -0,0 +1,151 @@ +//go:build integration + +package data + +import ( + "context" + "os" + "testing" + "time" + + "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/testutil/testdb" + workflow "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/google/uuid" +) + +func TestClaimLeaseAndCompleteWorkflowDispatch(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + userID, workspaceID, repositoryID, operationID := uuid.New(), uuid.New(), uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Workflow test',$2,$3)`, workspaceID, "workflow-"+workspaceID.String(), userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'provisioning',$5)`, repositoryID, workspaceID, "github.com/example/"+repositoryID.String(), "/tmp/"+repositoryID.String()+".git", userID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO repository_operations ( + id,repository_id,actor_user_id,repository_version,kind, + requested_provider_type,requested_remote_url,requested_normalized_url,requested_ref, + previous_provider_type,previous_remote_url,previous_normalized_url,previous_ref,status +) VALUES ( + $1,$2,$3,1,'purge', + 'github','https://github.com/example/repo.git',$4,'main', + 'github','https://github.com/example/repo.git',$4,'main','queued' +)`, operationID, repositoryID, userID, "github.com/example/"+repositoryID.String()); err != nil { + t.Fatal(err) + } + dispatch, err := workflow.NewDispatch(workflow.AggregateRepositoryOperation, operationID, workflow.WorkflowRepositoryOperation, workflow.Payload{AggregateID: operationID, RepositoryID: repositoryID}) + if err != nil { + t.Fatal(err) + } + queries := sqlc.New(pool) + if _, err := queries.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{ID: dispatch.ID, AggregateType: dispatch.AggregateType, AggregateID: dispatch.AggregateID, WorkflowName: dispatch.WorkflowName, Payload: dispatch.Payload}); err != nil { + t.Fatal(err) + } + + store := NewStore(pool) + claimed, err := store.Claim(ctx, 10, time.Minute) + if err != nil { + t.Fatal(err) + } + var claimedDispatch *workflow.Dispatch + for i := range claimed { + if claimed[i].ID == dispatch.ID { + claimedDispatch = &claimed[i] + break + } + } + if claimedDispatch == nil || claimedDispatch.Attempts != 1 { + t.Fatalf("dispatch was not claimed exactly once: %#v", claimed) + } + claimedAgain, err := store.Claim(ctx, 10, time.Minute) + if err != nil { + t.Fatal(err) + } + for _, item := range claimedAgain { + if item.ID == dispatch.ID { + t.Fatal("leased dispatch was claimed again") + } + } + if err := store.Complete(ctx, *claimedDispatch, "hatchet-run-id"); err != nil { + t.Fatal(err) + } + var status, workflowRunID string + if err := pool.QueryRow(ctx, `SELECT d.status,o.workflow_run_id FROM workflow_dispatches d JOIN repository_operations o ON o.id=d.aggregate_id WHERE d.id=$1`, dispatch.ID).Scan(&status, &workflowRunID); err != nil { + t.Fatal(err) + } + if status != "dispatched" || workflowRunID != "hatchet-run-id" { + t.Fatalf("unexpected completed dispatch: status=%q workflowRunID=%q", status, workflowRunID) + } +} + +func TestHighAttemptDispatchRemainsPendingUntilCancelled(t *testing.T) { + databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("MOONCODE_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool := testdb.Open(t, databaseURL) + + dispatchID, aggregateID := uuid.New(), uuid.New() + if _, err := pool.Exec(ctx, ` +INSERT INTO workflow_dispatches ( + id,aggregate_type,aggregate_id,workflow_name,payload,status,attempts +) VALUES ( + $1,'analysis_run',$2,'analysis-run',$3,'pending',99 +)`, dispatchID, aggregateID, `{"aggregateId":"`+aggregateID.String()+`","repositoryId":"`+uuid.NewString()+`"}`); err != nil { + t.Fatal(err) + } + + store := NewStore(pool) + claimed, err := store.Claim(ctx, 100, time.Minute) + if err != nil { + t.Fatal(err) + } + var item *workflow.Dispatch + for i := range claimed { + if claimed[i].ID == dispatchID { + item = &claimed[i] + break + } + } + if item == nil || item.Attempts != 100 { + t.Fatalf("high-attempt dispatch was not claimed: %#v", item) + } + if err := store.Delay(ctx, dispatchID, time.Now().Add(time.Minute), "Hatchet unavailable"); err != nil { + t.Fatal(err) + } + + var status string + var attempts int32 + if err := pool.QueryRow(ctx, `SELECT status,attempts FROM workflow_dispatches WHERE id=$1`, dispatchID).Scan(&status, &attempts); err != nil { + t.Fatal(err) + } + if status != "pending" || attempts != 100 { + t.Fatalf("delayed dispatch = (%q,%d), want (pending,100)", status, attempts) + } + + queries := sqlc.New(pool) + if err := queries.CancelWorkflowDispatch(ctx, sqlc.CancelWorkflowDispatchParams{ + AggregateType: workflow.AggregateAnalysisRun, + AggregateID: aggregateID, + }); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT status FROM workflow_dispatches WHERE id=$1`, dispatchID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "cancelled" { + t.Fatalf("cancelled dispatch status = %q", status) + } +} diff --git a/internal/workflow/dispatcher.go b/internal/workflow/dispatcher.go new file mode 100644 index 0000000..62c6f5b --- /dev/null +++ b/internal/workflow/dispatcher.go @@ -0,0 +1,160 @@ +package workflow + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + biz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/google/uuid" +) + +type Store interface { + Claim(context.Context, int32, time.Duration) ([]biz.Dispatch, error) + IsPending(context.Context, uuid.UUID) (bool, error) + Complete(context.Context, biz.Dispatch, string) error + Delay(context.Context, uuid.UUID, time.Time, string) error +} + +type Launcher interface { + DispatchRepository(context.Context, uuid.UUID, uuid.UUID) (string, error) + DispatchAnalysis(context.Context, uuid.UUID, uuid.UUID) (string, error) + DispatchNotification(context.Context, uuid.UUID, uuid.UUID) (string, error) + DispatchRetention(context.Context, uuid.UUID, uuid.UUID) (string, error) + CancelWorkflow(context.Context, string) error +} + +type Dispatcher struct { + store Store + launcher Launcher + pollInterval time.Duration + lease time.Duration + batchSize int32 + now func() time.Time + logger *slog.Logger +} + +func NewDispatcher(store Store, launcher Launcher, options ...Option) *Dispatcher { + dispatcher := &Dispatcher{ + store: store, launcher: launcher, + pollInterval: time.Second, lease: time.Minute, batchSize: 20, + now: time.Now, logger: slog.Default(), + } + for _, option := range options { + option(dispatcher) + } + + return dispatcher +} + +func (d *Dispatcher) Run(ctx context.Context) error { + if err := d.RunOnce(ctx); err != nil { + return fmt.Errorf("initial workflow outbox dispatch: %w", err) + } + + ticker := time.NewTicker(d.pollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := d.RunOnce(ctx); err != nil { + d.logger.ErrorContext(ctx, "workflow outbox dispatch failed", "error", err) + } + } + } +} + +func (d *Dispatcher) RunOnce(ctx context.Context) error { + items, err := d.store.Claim(ctx, d.batchSize, d.lease) + if err != nil { + return fmt.Errorf("claim workflow dispatches: %w", err) + } + + for _, item := range items { + if err := d.dispatch(ctx, item); err != nil { + d.logger.WarnContext(ctx, "workflow dispatch delayed", "dispatch_id", item.ID, "workflow", item.WorkflowName, "attempt", item.Attempts, "error", err) + } + } + + return nil +} + +func (d *Dispatcher) dispatch(ctx context.Context, item biz.Dispatch) error { + pending, err := d.store.IsPending(ctx, item.ID) + if err != nil { + return err + } + if !pending { + return nil + } + + var payload biz.Payload + if err := json.Unmarshal(item.Payload, &payload); err != nil { + return d.delay(ctx, item, fmt.Errorf("decode workflow payload: %w", err)) + } + if payload.AggregateID == uuid.Nil || payload.AggregateID != item.AggregateID { + return d.delay(ctx, item, errors.New("workflow payload identifiers are invalid")) + } + + var workflowRunID string + switch item.WorkflowName { + case biz.WorkflowRepositoryOperation: + if payload.RepositoryID == uuid.Nil { + return d.delay(ctx, item, errors.New("repository workflow has no repository ID")) + } + workflowRunID, err = d.launcher.DispatchRepository(ctx, payload.AggregateID, payload.RepositoryID) + case biz.WorkflowAnalysisRun: + if payload.RepositoryID == uuid.Nil { + return d.delay(ctx, item, errors.New("analysis workflow has no repository ID")) + } + workflowRunID, err = d.launcher.DispatchAnalysis(ctx, payload.AggregateID, payload.RepositoryID) + case biz.WorkflowNotificationDelivery: + if payload.ChannelID == uuid.Nil { + return d.delay(ctx, item, errors.New("notification workflow has no channel ID")) + } + workflowRunID, err = d.launcher.DispatchNotification(ctx, payload.AggregateID, payload.ChannelID) + case biz.WorkflowRetentionCleanup: + if payload.WorkspaceID == uuid.Nil { + return d.delay(ctx, item, errors.New("retention workflow has no workspace ID")) + } + workflowRunID, err = d.launcher.DispatchRetention(ctx, payload.AggregateID, payload.WorkspaceID) + default: + err = fmt.Errorf("unsupported workflow %q", item.WorkflowName) + } + if err != nil { + return d.delay(ctx, item, err) + } + if workflowRunID == "" { + return d.delay(ctx, item, errors.New("hatchet returned an empty workflow run ID")) + } + + err = d.store.Complete(ctx, item, workflowRunID) + if errors.Is(err, biz.ErrDispatchNotPending) { + if cancelErr := d.launcher.CancelWorkflow(ctx, workflowRunID); cancelErr != nil { + return fmt.Errorf("cancel workflow for non-pending dispatch: %w", cancelErr) + } + + return nil + } + + return err +} + +func (d *Dispatcher) delay(ctx context.Context, item biz.Dispatch, cause error) error { + message := cause.Error() + if len(message) > 1000 { + message = message[:1000] + } + attempt := max(item.Attempts, 1) + backoff := time.Second << min(attempt-1, 6) + if err := d.store.Delay(ctx, item.ID, d.now().Add(backoff), message); err != nil { + return errors.Join(cause, err) + } + + return cause +} diff --git a/internal/workflow/dispatcher_test.go b/internal/workflow/dispatcher_test.go new file mode 100644 index 0000000..d7748b9 --- /dev/null +++ b/internal/workflow/dispatcher_test.go @@ -0,0 +1,229 @@ +package workflow + +import ( + "context" + "errors" + "testing" + "time" + + biz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/google/uuid" +) + +type dispatchStore struct { + items []biz.Dispatch + pending bool + completed string + delays []dispatchDelay + claimErr error + completeErr error +} + +type dispatchDelay struct { + availableAt time.Time + message string +} + +func (s *dispatchStore) Claim(context.Context, int32, time.Duration) ([]biz.Dispatch, error) { + return s.items, s.claimErr +} + +func TestDispatcherReturnsInitialClaimFailure(t *testing.T) { + want := errors.New("database unavailable") + dispatcher := NewDispatcher(&dispatchStore{claimErr: want}, &launcher{}) + + err := dispatcher.Run(context.Background()) + if !errors.Is(err, want) { + t.Fatalf("Run() error = %v, want %v", err, want) + } +} +func (s *dispatchStore) IsPending(context.Context, uuid.UUID) (bool, error) { return s.pending, nil } +func (s *dispatchStore) Complete(_ context.Context, _ biz.Dispatch, runID string) error { + s.completed = runID + return s.completeErr +} +func (s *dispatchStore) Delay(_ context.Context, _ uuid.UUID, availableAt time.Time, message string) error { + s.delays = append(s.delays, dispatchDelay{availableAt: availableAt, message: message}) + return nil +} + +type launcher struct { + notificationID uuid.UUID + channelID uuid.UUID + cleanupID uuid.UUID + workspaceID uuid.UUID + dispatches int + cancelled string + err error +} + +func (l *launcher) DispatchRepository(context.Context, uuid.UUID, uuid.UUID) (string, error) { + l.dispatches++ + return "repository-run", l.err +} +func (l *launcher) DispatchAnalysis(context.Context, uuid.UUID, uuid.UUID) (string, error) { + l.dispatches++ + return "analysis-run", l.err +} +func (l *launcher) DispatchNotification(_ context.Context, notificationID, channelID uuid.UUID) (string, error) { + l.dispatches++ + l.notificationID, l.channelID = notificationID, channelID + return "notification-run", l.err +} +func (l *launcher) DispatchRetention(_ context.Context, cleanupID, workspaceID uuid.UUID) (string, error) { + l.dispatches++ + l.cleanupID, l.workspaceID = cleanupID, workspaceID + return "retention-run", l.err +} +func (l *launcher) CancelWorkflow(_ context.Context, workflowRunID string) error { + l.cancelled = workflowRunID + return nil +} + +func TestDispatcherDispatchesNotification(t *testing.T) { + notificationID, repositoryID, channelID := uuid.New(), uuid.New(), uuid.New() + dispatch, err := biz.NewDispatch(biz.AggregateNotification, notificationID, biz.WorkflowNotificationDelivery, biz.Payload{AggregateID: notificationID, RepositoryID: repositoryID, ChannelID: channelID}) + if err != nil { + t.Fatal(err) + } + dispatch.Attempts = 1 + store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: true} + launcher := &launcher{} + + if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if launcher.notificationID != notificationID || launcher.channelID != channelID { + t.Fatalf("unexpected notification launch: %s %s", launcher.notificationID, launcher.channelID) + } + if store.completed != "notification-run" || len(store.delays) != 0 { + t.Fatalf("unexpected completion state: completed=%q delays=%d", store.completed, len(store.delays)) + } +} + +func TestDispatcherDelaysFailedLaunch(t *testing.T) { + runID, repositoryID := uuid.New(), uuid.New() + dispatch, err := biz.NewDispatch(biz.AggregateAnalysisRun, runID, biz.WorkflowAnalysisRun, biz.Payload{AggregateID: runID, RepositoryID: repositoryID}) + if err != nil { + t.Fatal(err) + } + dispatch.Attempts = 1 + store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: true} + launcher := &launcher{err: errors.New("Hatchet unavailable")} + + if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if len(store.delays) != 1 || store.completed != "" { + t.Fatalf("unexpected retry state: completed=%q delays=%d", store.completed, len(store.delays)) + } +} + +func TestDispatcherKeepsHighAttemptFailurePendingWithCappedBackoff(t *testing.T) { + runID, repositoryID := uuid.New(), uuid.New() + dispatch, err := biz.NewDispatch( + biz.AggregateAnalysisRun, + runID, + biz.WorkflowAnalysisRun, + biz.Payload{AggregateID: runID, RepositoryID: repositoryID}, + ) + if err != nil { + t.Fatal(err) + } + dispatch.Attempts = 100 + + now := time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC) + store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: true} + launcher := &launcher{err: errors.New("Hatchet unavailable")} + + if err := NewDispatcher(store, launcher, WithClock(func() time.Time { return now })).RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if len(store.delays) != 1 { + t.Fatalf("delay count = %d, want 1", len(store.delays)) + } + if want := now.Add(64 * time.Second); !store.delays[0].availableAt.Equal(want) { + t.Fatalf("availableAt = %s, want %s", store.delays[0].availableAt, want) + } + if store.completed != "" || launcher.dispatches != 1 { + t.Fatalf("unexpected retry state: completed=%q dispatches=%d", store.completed, launcher.dispatches) + } +} + +func TestDispatcherDoesNotLaunchCancelledDispatch(t *testing.T) { + runID, repositoryID := uuid.New(), uuid.New() + dispatch, err := biz.NewDispatch( + biz.AggregateAnalysisRun, + runID, + biz.WorkflowAnalysisRun, + biz.Payload{AggregateID: runID, RepositoryID: repositoryID}, + ) + if err != nil { + t.Fatal(err) + } + dispatch.Attempts = 1 + + store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: false} + launcher := &launcher{} + + if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if launcher.dispatches != 0 || store.completed != "" || len(store.delays) != 0 { + t.Fatalf("cancelled dispatch changed: dispatches=%d completed=%q delays=%d", launcher.dispatches, store.completed, len(store.delays)) + } +} + +func TestDispatcherCancelsLaunchedWorkflowWhenDispatchWasCancelledDuringLaunch(t *testing.T) { + runID, repositoryID := uuid.New(), uuid.New() + dispatch, err := biz.NewDispatch( + biz.AggregateAnalysisRun, + runID, + biz.WorkflowAnalysisRun, + biz.Payload{AggregateID: runID, RepositoryID: repositoryID}, + ) + if err != nil { + t.Fatal(err) + } + dispatch.Attempts = 1 + + store := &dispatchStore{ + items: []biz.Dispatch{dispatch}, + pending: true, + completeErr: biz.ErrDispatchNotPending, + } + launcher := &launcher{} + + if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if launcher.dispatches != 1 || launcher.cancelled != "analysis-run" || len(store.delays) != 0 { + t.Fatalf("launch cancellation mismatch: dispatches=%d cancelled=%q delays=%d", launcher.dispatches, launcher.cancelled, len(store.delays)) + } +} + +func TestDispatcherDispatchesWorkspaceRetention(t *testing.T) { + cleanupID, workspaceID := uuid.New(), uuid.New() + dispatch, err := biz.NewDispatch( + biz.AggregateRetentionCleanup, + cleanupID, + biz.WorkflowRetentionCleanup, + biz.Payload{AggregateID: cleanupID, WorkspaceID: workspaceID}, + ) + if err != nil { + t.Fatal(err) + } + dispatch.Attempts = 1 + store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: true} + launcher := &launcher{} + + if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if launcher.cleanupID != cleanupID || launcher.workspaceID != workspaceID { + t.Fatalf("unexpected retention launch: %s %s", launcher.cleanupID, launcher.workspaceID) + } + if store.completed != "retention-run" || len(store.delays) != 0 { + t.Fatalf("unexpected completion state: completed=%q delays=%d", store.completed, len(store.delays)) + } +} diff --git a/internal/workflow/options.go b/internal/workflow/options.go new file mode 100644 index 0000000..a94e3ac --- /dev/null +++ b/internal/workflow/options.go @@ -0,0 +1,28 @@ +package workflow + +import ( + "log/slog" + "time" +) + +type Option func(*Dispatcher) + +func WithPollInterval(value time.Duration) Option { + return func(dispatcher *Dispatcher) { dispatcher.pollInterval = value } +} + +func WithLease(value time.Duration) Option { + return func(dispatcher *Dispatcher) { dispatcher.lease = value } +} + +func WithBatchSize(value int32) Option { + return func(dispatcher *Dispatcher) { dispatcher.batchSize = value } +} + +func WithClock(now func() time.Time) Option { + return func(dispatcher *Dispatcher) { dispatcher.now = now } +} + +func WithLogger(logger *slog.Logger) Option { + return func(dispatcher *Dispatcher) { dispatcher.logger = logger } +} diff --git a/migrations/embed.go b/migrations/embed.go index 42f33a8..25ce955 100644 --- a/migrations/embed.go +++ b/migrations/embed.go @@ -1,9 +1,8 @@ -// Package migrations exposes MoonCode's forward-only PostgreSQL migrations. package migrations import "embed" -// PostgreSQL contains the migration files rooted at postgres/. +// Postgres contains all MoonCode-owned schema migrations. // //go:embed postgres/*.sql -var PostgreSQL embed.FS +var Postgres embed.FS diff --git a/migrations/postgres/000001_initial.down.sql b/migrations/postgres/000001_initial.down.sql new file mode 100644 index 0000000..a0e57e1 --- /dev/null +++ b/migrations/postgres/000001_initial.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audit_events, conversation_bindings, channel_identity_links, channel_external_identities, messages, conversations, notifications, channel_subscriptions, channels, retention_cleanups, retention_cleanup_schedules, workflow_dispatches, analysis_reports, analysis_runs, analysis_profile_versions, analysis_profiles, repository_operations, commit_snapshots, repository_source_keys, repositories, provider_connections, workspace_invitations, workspace_members, workspaces, oauth_identities, users CASCADE; diff --git a/migrations/postgres/000001_initial.up.sql b/migrations/postgres/000001_initial.up.sql index 5e9ed23..1d411f7 100644 --- a/migrations/postgres/000001_initial.up.sql +++ b/migrations/postgres/000001_initial.up.sql @@ -1,295 +1,493 @@ CREATE TABLE users ( id uuid PRIMARY KEY, - issuer text NOT NULL, - external_subject text NOT NULL, - username text NOT NULL, - email text NOT NULL DEFAULT '', display_name text NOT NULL DEFAULT '', - status text NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'active', 'suspended', 'deleted')), - activated_at timestamptz, - suspended_at timestamptz, - last_seen_at timestamptz, - terms_version text NOT NULL DEFAULT '', - privacy_version text NOT NULL DEFAULT '', - agreements_accepted_at timestamptz, - deleted_at timestamptz, + username text NOT NULL DEFAULT '', + email text NOT NULL DEFAULT '', + status text NOT NULL CHECK (status IN ('pending', 'active', 'suspended', 'deleted')), + accepted_terms_at timestamptz, + accepted_privacy_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE oauth_identities ( + issuer text NOT NULL, + subject text NOT NULL, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (issuer, external_subject) + PRIMARY KEY (issuer, subject) ); CREATE TABLE workspaces ( id uuid PRIMARY KEY, name text NOT NULL, slug text NOT NULL UNIQUE, - kind text NOT NULL CHECK (kind IN ('personal', 'team')), + report_retention_days integer NOT NULL DEFAULT 90 + CHECK (report_retention_days BETWEEN 1 AND 3650), created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz + updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE workspace_members ( - workspace_id uuid NOT NULL REFERENCES workspaces(id), - user_id uuid NOT NULL REFERENCES users(id), - role text NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role text NOT NULL CHECK (role IN ('owner', 'admin', 'member')), created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (workspace_id, user_id) ); -CREATE INDEX workspace_members_user_idx ON workspace_members(user_id); CREATE TABLE workspace_invitations ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - email_normalized text NOT NULL, - role text NOT NULL CHECK (role IN ('admin', 'member', 'viewer')), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + email text NOT NULL, token_hash bytea NOT NULL UNIQUE, - invited_by uuid NOT NULL REFERENCES users(id), + created_by uuid NOT NULL REFERENCES users(id), expires_at timestamptz NOT NULL, accepted_at timestamptz, - accepted_by uuid REFERENCES users(id), revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE provider_connections ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_type text NOT NULL CHECK (provider_type IN ('github', 'gitlab')), + base_url text NOT NULL, + token_ciphertext bytea NOT NULL, + token_nonce bytea NOT NULL, + key_version integer NOT NULL DEFAULT 1, + credential_version bigint NOT NULL DEFAULT 1, + provider_account_id text, + login text, + display_name text, + scopes text[] NOT NULL DEFAULT '{}', + is_default boolean NOT NULL DEFAULT false, + status text NOT NULL CHECK (status IN ('active', 'invalid', 'revoked')), + last_validated_at timestamptz, + last_used_at timestamptz, + last_error_code text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); -CREATE INDEX workspace_invitations_workspace_idx - ON workspace_invitations(workspace_id, created_at DESC); -CREATE INDEX workspace_invitations_active_email_idx - ON workspace_invitations(workspace_id, email_normalized) - WHERE accepted_at IS NULL AND revoked_at IS NULL; -CREATE TABLE account_audit_logs ( +CREATE UNIQUE INDEX provider_connections_active_identity_idx + ON provider_connections (user_id, provider_type, base_url) + WHERE status <> 'revoked'; + +CREATE UNIQUE INDEX provider_connections_default_idx + ON provider_connections (user_id, provider_type) + WHERE is_default AND status <> 'revoked'; + +CREATE TABLE repositories ( id uuid PRIMARY KEY, - user_id uuid NOT NULL REFERENCES users(id), - actor_user_id uuid REFERENCES users(id), - action text NOT NULL, - result text NOT NULL CHECK (result IN ('success', 'failure')), - provider text NOT NULL DEFAULT '', - request_id text NOT NULL DEFAULT '', - metadata jsonb NOT NULL DEFAULT '{}'::jsonb, - occurred_at timestamptz NOT NULL DEFAULT now() + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + provider_type text NOT NULL CHECK (provider_type IN ('github', 'gitlab')), + name text NOT NULL, + remote_url text NOT NULL, + normalized_url text NOT NULL, + configured_ref text NOT NULL, + config_version bigint NOT NULL DEFAULT 1, + git_path text NOT NULL UNIQUE, + status text NOT NULL CHECK (status IN ('provisioning', 'syncing', 'ready', 'failed', 'deleting', 'deleted')), + current_snapshot_id uuid, + mirror_size_bytes bigint NOT NULL DEFAULT 0 CHECK (mirror_size_bytes >= 0), + last_sync_at timestamptz, + last_error_code text, + last_error_message text, + archived_at timestamptz, + deleted_at timestamptz, + created_by uuid NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (workspace_id, normalized_url) +); + +CREATE TABLE repository_source_keys ( + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + normalized_url text NOT NULL CHECK (btrim(normalized_url) <> ''), + repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, normalized_url), + UNIQUE (repository_id, normalized_url) ); -CREATE INDEX account_audit_logs_user_idx - ON account_audit_logs(user_id, occurred_at DESC, id DESC); -CREATE TABLE secrets ( +CREATE TABLE repository_operations ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - resource_type text NOT NULL, - resource_id uuid NOT NULL, - ciphertext bytea NOT NULL, - nonce bytea NOT NULL, - wrapped_key bytea NOT NULL, - wrapped_key_nonce bytea NOT NULL, - key_version integer NOT NULL, + repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + actor_user_id uuid NOT NULL REFERENCES users(id), + provider_connection_id uuid REFERENCES provider_connections(id) ON DELETE SET NULL, + credential_version bigint, + repository_version bigint NOT NULL, + kind text NOT NULL CHECK (kind IN ('provision', 'refresh', 'update', 'purge')), + requested_provider_type text NOT NULL CHECK (requested_provider_type IN ('github', 'gitlab')), + requested_remote_url text NOT NULL CHECK (btrim(requested_remote_url) <> ''), + requested_normalized_url text NOT NULL CHECK (btrim(requested_normalized_url) <> ''), + requested_ref text NOT NULL CHECK (btrim(requested_ref) <> ''), + previous_provider_type text NOT NULL CHECK (previous_provider_type IN ('github', 'gitlab')), + previous_remote_url text NOT NULL CHECK (btrim(previous_remote_url) <> ''), + previous_normalized_url text NOT NULL CHECK (btrim(previous_normalized_url) <> ''), + previous_ref text NOT NULL CHECK (btrim(previous_ref) <> ''), + status text NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + outcome text CHECK (outcome IN ('changed', 'no_change')), + resolved_commit_sha text, + snapshot_id uuid, + workflow_run_id text, + error_message text, created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() + started_at timestamptz, + finished_at timestamptz ); -CREATE INDEX secrets_workspace_idx ON secrets(workspace_id); -CREATE TABLE scm_connections ( +CREATE TABLE commit_snapshots ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - type text NOT NULL, - name text NOT NULL, - base_url text NOT NULL, - auth_type text NOT NULL, - secret_ref uuid REFERENCES secrets(id), + repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + commit_sha text NOT NULL, + source_ref text NOT NULL, + git_ref text NOT NULL UNIQUE, + author_name text, + authored_at timestamptz, + title text, + source_state text NOT NULL CHECK (source_state IN ('available', 'purging', 'purged')), + purge_cleanup_id uuid, created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - UNIQUE (workspace_id, name) + UNIQUE (repository_id, commit_sha) ); -CREATE INDEX scm_connections_workspace_idx ON scm_connections(workspace_id) WHERE deleted_at IS NULL; -CREATE TABLE repositories ( +ALTER TABLE repositories + ADD CONSTRAINT repositories_current_snapshot_fk + FOREIGN KEY (current_snapshot_id) REFERENCES commit_snapshots(id) ON DELETE SET NULL; + +ALTER TABLE repository_operations + ADD CONSTRAINT repository_operations_snapshot_fk + FOREIGN KEY (snapshot_id) REFERENCES commit_snapshots(id) ON DELETE SET NULL; + +CREATE TABLE analysis_profiles ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - connection_id uuid REFERENCES scm_connections(id), - name text NOT NULL, - clone_url text NOT NULL, - normalized_url text NOT NULL, - ref text NOT NULL DEFAULT '', - current_commit_sha text NOT NULL DEFAULT '', - state text NOT NULL CHECK (state IN ('pending', 'ready', 'syncing', 'failed', 'deleting')), - last_error_code text NOT NULL DEFAULT '', - last_error_message text NOT NULL DEFAULT '', - synced_at timestamptz, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name text NOT NULL CHECK (btrim(name) <> ''), + current_version integer NOT NULL CHECK (current_version > 0), + archived_at timestamptz, + created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - UNIQUE (workspace_id, normalized_url) + UNIQUE (workspace_id, id) ); -CREATE INDEX repositories_workspace_idx ON repositories(workspace_id) WHERE deleted_at IS NULL; -CREATE TABLE jobs ( +CREATE UNIQUE INDEX analysis_profiles_workspace_name_key + ON analysis_profiles (workspace_id, lower(name)) + WHERE archived_at IS NULL; + +CREATE TABLE analysis_profile_versions ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - type text NOT NULL, - payload jsonb NOT NULL, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + profile_id uuid NOT NULL, + version integer NOT NULL CHECK (version > 0), + dimension_key text NOT NULL CHECK (dimension_key = 'code_scale'), + definition jsonb NOT NULL CHECK (jsonb_typeof(definition) = 'object'), + created_by uuid NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (workspace_id, profile_id, version), + FOREIGN KEY (workspace_id, profile_id) REFERENCES analysis_profiles(workspace_id, id) ON DELETE CASCADE +); + +CREATE TABLE analysis_runs ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + snapshot_id uuid NOT NULL REFERENCES commit_snapshots(id), + commit_sha text NOT NULL, + requested_by uuid NOT NULL REFERENCES users(id), + dimension_key text NOT NULL, + profile_id uuid NOT NULL, + profile_version text NOT NULL, + profile_snapshot jsonb NOT NULL CHECK (jsonb_typeof(profile_snapshot) = 'object'), + analyzer_version text NOT NULL, + idempotency_key text NOT NULL CHECK (idempotency_key ~ '^[0-9a-f]{64}$'), + attempt integer NOT NULL CHECK (attempt > 0), + rerun_of uuid REFERENCES analysis_runs(id) ON DELETE SET NULL, status text NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), - idempotency_key text NOT NULL, - attempt integer NOT NULL DEFAULT 0, - max_attempts integer NOT NULL DEFAULT 5, - lease_owner text, - lease_until timestamptz, - fencing_token bigint NOT NULL DEFAULT 0, - run_after timestamptz NOT NULL DEFAULT now(), - trace_context jsonb NOT NULL DEFAULT '{}'::jsonb, - last_error_code text NOT NULL DEFAULT '', - last_error_message text NOT NULL DEFAULT '', + stage text NOT NULL DEFAULT 'queued' CHECK (stage IN ('queued', 'prepare', 'authorize', 'checkout', 'analyze', 'persist', 'workflow', 'complete', 'cancelled')), + report_id uuid, + workflow_run_id text, + failed_stage text, + error_code text, + error_message text, + retryable boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), started_at timestamptz, finished_at timestamptz, - updated_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (type, idempotency_key) + CONSTRAINT analysis_runs_failure_fields_check CHECK ( + (status = 'failed' AND failed_stage IS NOT NULL AND error_code IS NOT NULL) + OR (status <> 'failed' AND failed_stage IS NULL AND error_code IS NULL) + ), + CONSTRAINT analysis_runs_retryable_status_check CHECK ( + NOT retryable OR status IN ('failed', 'cancelled') + ), + CONSTRAINT analysis_runs_idempotency_attempt_key UNIQUE (workspace_id, idempotency_key, attempt), + FOREIGN KEY (workspace_id, profile_id) REFERENCES analysis_profiles(workspace_id, id) ); -CREATE INDEX jobs_claim_idx ON jobs(type, status, run_after, created_at); -CREATE INDEX jobs_workspace_idx ON jobs(workspace_id, created_at DESC); -CREATE UNIQUE INDEX jobs_active_repository_sync_idx - ON jobs ((payload->>'repositoryId')) - WHERE type = 'repository.sync' AND status IN ('queued', 'running'); - -CREATE TABLE repository_sync_controls ( - repository_id uuid PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - next_allowed_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() + +CREATE TABLE analysis_reports ( + id uuid PRIMARY KEY, + analysis_run_id uuid NOT NULL UNIQUE REFERENCES analysis_runs(id) ON DELETE CASCADE, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + snapshot_id uuid NOT NULL REFERENCES commit_snapshots(id), + commit_sha text NOT NULL, + source_ref text NOT NULL, + commit_author_name text, + commit_authored_at timestamptz, + commit_title text, + dimension_key text NOT NULL, + profile_id uuid NOT NULL, + profile_version text NOT NULL, + profile_snapshot jsonb NOT NULL CHECK (jsonb_typeof(profile_snapshot) = 'object'), + analyzer_version text NOT NULL, + execution_environment text NOT NULL, + started_at timestamptz NOT NULL, + finished_at timestamptz NOT NULL, + duration_ms bigint NOT NULL CHECK (duration_ms >= 0), + result jsonb NOT NULL, + raw_artifact jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, profile_id) REFERENCES analysis_profiles(workspace_id, id) ); -CREATE INDEX repository_sync_controls_workspace_idx - ON repository_sync_controls(workspace_id, next_allowed_at); -CREATE TABLE channel_instances ( +CREATE INDEX analysis_reports_workspace_idx + ON analysis_reports (workspace_id, created_at DESC); + +ALTER TABLE analysis_runs + ADD CONSTRAINT analysis_runs_report_fk + FOREIGN KEY (report_id) REFERENCES analysis_reports(id) ON DELETE SET NULL; + +CREATE TABLE workflow_dispatches ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - type text NOT NULL, + aggregate_type text NOT NULL, + aggregate_id uuid NOT NULL, + workflow_name text NOT NULL, + payload jsonb NOT NULL, + status text NOT NULL CHECK (status IN ('pending', 'dispatched', 'cancelled')), + attempts integer NOT NULL DEFAULT 0, + available_at timestamptz NOT NULL DEFAULT now(), + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + dispatched_at timestamptz +); + +CREATE INDEX workflow_dispatches_pending_idx + ON workflow_dispatches (available_at, created_at) + WHERE status = 'pending'; + +CREATE TABLE retention_cleanup_schedules ( + workspace_id uuid PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE, + next_run_at timestamptz NOT NULL DEFAULT clock_timestamp(), + last_run_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX retention_cleanup_schedules_due_idx + ON retention_cleanup_schedules (next_run_at); + +CREATE TABLE retention_cleanups ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + scheduled_for timestamptz NOT NULL, + retention_days integer NOT NULL CHECK (retention_days BETWEEN 1 AND 3650), + status text NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')), + workflow_run_id text, + deleted_run_count integer NOT NULL DEFAULT 0 CHECK (deleted_run_count >= 0), + purged_snapshot_count integer NOT NULL DEFAULT 0 CHECK (purged_snapshot_count >= 0), + requeued_repository_count integer NOT NULL DEFAULT 0 CHECK (requeued_repository_count >= 0), + error_message text, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + started_at timestamptz, + finished_at timestamptz, + UNIQUE (workspace_id, scheduled_for) +); + +ALTER TABLE commit_snapshots + ADD CONSTRAINT commit_snapshots_purge_cleanup_fk + FOREIGN KEY (purge_cleanup_id) REFERENCES retention_cleanups(id) ON DELETE SET NULL; + +CREATE INDEX retention_cleanups_workspace_created_idx + ON retention_cleanups (workspace_id, created_at DESC, id DESC); + +CREATE TABLE channels ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + type text NOT NULL CHECK (type IN ('feishu', 'dingtalk')), name text NOT NULL, enabled boolean NOT NULL DEFAULT false, - config jsonb NOT NULL DEFAULT '{}'::jsonb, - secret_ref uuid REFERENCES secrets(id), + runtime_status text NOT NULL DEFAULT 'disabled' CHECK (runtime_status IN ('disabled', 'starting', 'connected', 'error')), + secret_ciphertext bytea, + secret_nonce bytea, + key_version integer, config_version bigint NOT NULL DEFAULT 1, + config jsonb NOT NULL DEFAULT '{}', + last_connected_at timestamptz, + last_error_message text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - UNIQUE (workspace_id, name) + UNIQUE (workspace_id, id) ); -CREATE INDEX channel_instances_workspace_idx ON channel_instances(workspace_id) WHERE deleted_at IS NULL; -CREATE TABLE channel_leases ( - channel_instance_id uuid PRIMARY KEY REFERENCES channel_instances(id) ON DELETE CASCADE, - owner text NOT NULL, - lease_until timestamptz NOT NULL, - fencing_token bigint NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now() +CREATE TABLE channel_subscriptions ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id uuid NOT NULL, + event_type text NOT NULL CHECK (event_type IN ('analysis.started', 'analysis.succeeded', 'analysis.failed')), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (workspace_id, channel_id, event_type), + FOREIGN KEY (workspace_id, channel_id) REFERENCES channels(workspace_id, id) ON DELETE CASCADE ); -CREATE TABLE channel_runtime_status ( - channel_instance_id uuid PRIMARY KEY REFERENCES channel_instances(id) ON DELETE CASCADE, - state text NOT NULL, - backend_instance_id text NOT NULL DEFAULT '', - fencing_token bigint NOT NULL DEFAULT 0, - last_connected_at timestamptz, - last_error_code text NOT NULL DEFAULT '', - last_error_message varchar(2048) NOT NULL DEFAULT '', - updated_at timestamptz NOT NULL DEFAULT now() +CREATE TABLE notifications ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + analysis_run_id uuid NOT NULL REFERENCES analysis_runs(id) ON DELETE CASCADE, + channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + event_type text NOT NULL CHECK (event_type IN ('analysis.started', 'analysis.succeeded', 'analysis.failed')), + status text NOT NULL CHECK (status IN ('queued', 'running', 'delivered', 'failed', 'cancelled')), + workflow_run_id text, + error_message text, + created_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + finished_at timestamptz, + UNIQUE (analysis_run_id, channel_id, event_type) ); -CREATE TABLE im_conversations ( +CREATE INDEX notifications_channel_idx + ON notifications (channel_id, created_at DESC); + +CREATE TABLE conversations ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - channel_instance_id uuid NOT NULL REFERENCES channel_instances(id), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE, external_id text NOT NULL, type text NOT NULL, title text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (channel_instance_id, external_id) + UNIQUE (channel_id, external_id) ); -CREATE INDEX im_conversations_workspace_idx ON im_conversations(workspace_id, updated_at DESC); -CREATE TABLE im_senders ( +CREATE TABLE messages ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - channel_type text NOT NULL, - canonical_id text NOT NULL, - display_name text NOT NULL DEFAULT '', + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + external_id text NOT NULL, + sender_canonical_id text NOT NULL, + sender_display_name text NOT NULL DEFAULT '', + content jsonb NOT NULL, + occurred_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (workspace_id, canonical_id) + UNIQUE (channel_id, external_id) ); -CREATE TABLE im_messages ( +CREATE TABLE channel_external_identities ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - channel_instance_id uuid NOT NULL REFERENCES channel_instances(id), - conversation_id uuid NOT NULL REFERENCES im_conversations(id), - sender_id uuid NOT NULL REFERENCES im_senders(id), - external_message_id text NOT NULL, - content jsonb NOT NULL, - occurred_at timestamptz NOT NULL, - received_at timestamptz NOT NULL DEFAULT now(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + sender_canonical_id text NOT NULL, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (channel_instance_id, external_message_id) -); -CREATE INDEX im_messages_workspace_idx ON im_messages(workspace_id, occurred_at DESC, id DESC); - -CREATE TABLE inbox_events ( - channel_instance_id uuid NOT NULL REFERENCES channel_instances(id), - external_event_id text NOT NULL, - payload_hash text NOT NULL, - received_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (channel_instance_id, external_event_id) + UNIQUE (channel_id, sender_canonical_id) ); -CREATE TABLE outbox_events ( +CREATE TABLE channel_identity_links ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - aggregate text NOT NULL, - aggregate_id uuid NOT NULL, - event_type text NOT NULL, - payload jsonb NOT NULL, - trace_context jsonb NOT NULL DEFAULT '{}'::jsonb, - lease_owner text, - lease_until timestamptz, - attempt integer NOT NULL DEFAULT 0, - next_attempt_at timestamptz NOT NULL DEFAULT now(), - last_error_code text NOT NULL DEFAULT '', - last_error_message text NOT NULL DEFAULT '', + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + channel_version bigint NOT NULL, + sender_canonical_id text NOT NULL, + token_hash bytea NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX channel_identity_links_sender_idx + ON channel_identity_links (channel_id, sender_canonical_id, created_at DESC); + +CREATE TABLE conversation_bindings ( + conversation_id uuid PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + bound_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), - published_at timestamptz + updated_at timestamptz NOT NULL DEFAULT now() ); -CREATE INDEX outbox_events_unpublished_idx - ON outbox_events(next_attempt_at, created_at) - WHERE published_at IS NULL; -CREATE TABLE audit_logs ( +CREATE TABLE audit_events ( id uuid PRIMARY KEY, - workspace_id uuid NOT NULL REFERENCES workspaces(id), - actor_user_id uuid REFERENCES users(id), - action text NOT NULL, - resource_type text NOT NULL, - resource_id uuid, - result text NOT NULL, - metadata jsonb NOT NULL DEFAULT '{}'::jsonb, - occurred_at timestamptz NOT NULL DEFAULT now() -); -CREATE INDEX audit_logs_workspace_idx ON audit_logs(workspace_id, occurred_at DESC, id DESC); - -CREATE TABLE mutation_controls ( - workspace_id uuid NOT NULL REFERENCES workspaces(id), - operation text NOT NULL, - resource_key text NOT NULL, - idempotency_key text NOT NULL, - request_hash text NOT NULL, + workspace_id uuid REFERENCES workspaces(id) ON DELETE SET NULL, + actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + action text NOT NULL CHECK (action <> ''), + resource_type text NOT NULL CHECK (resource_type <> ''), resource_id uuid, - status text NOT NULL CHECK (status IN ('active', 'succeeded', 'failed')), - active_until timestamptz NOT NULL, - next_allowed_at timestamptz NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (workspace_id, operation, resource_key), - UNIQUE (workspace_id, operation, idempotency_key) + metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(metadata) = 'object'), + created_at timestamptz NOT NULL DEFAULT now() ); -CREATE INDEX mutation_controls_expiry_idx - ON mutation_controls(status, active_until, next_allowed_at); + +CREATE INDEX audit_events_workspace_created_idx + ON audit_events (workspace_id, created_at DESC, id DESC) + WHERE workspace_id IS NOT NULL; + +CREATE INDEX audit_events_actor_created_idx + ON audit_events (actor_user_id, created_at DESC, id DESC) + WHERE actor_user_id IS NOT NULL; + +CREATE INDEX audit_events_resource_created_idx + ON audit_events (resource_type, resource_id, created_at DESC, id DESC) + WHERE resource_id IS NOT NULL; + +CREATE INDEX provider_connections_user_created_idx + ON provider_connections (user_id, created_at DESC, id DESC) + WHERE status <> 'revoked'; + +CREATE INDEX workspace_members_workspace_created_idx + ON workspace_members (workspace_id, created_at DESC, user_id DESC); + +CREATE INDEX workspace_invitations_workspace_created_idx + ON workspace_invitations (workspace_id, created_at DESC, id DESC); + +CREATE INDEX repositories_workspace_created_idx + ON repositories (workspace_id, created_at DESC, id DESC) + WHERE status <> 'deleted'; + +CREATE INDEX repository_operations_repository_created_idx + ON repository_operations (repository_id, created_at DESC, id DESC); + +CREATE INDEX commit_snapshots_repository_created_idx + ON commit_snapshots (repository_id, created_at DESC, id DESC); + +CREATE INDEX analysis_runs_repository_created_idx + ON analysis_runs (workspace_id, repository_id, created_at DESC, id DESC); + +CREATE INDEX analysis_runs_retention_idx + ON analysis_runs (workspace_id, finished_at, id) + WHERE status IN ('succeeded', 'failed', 'cancelled'); + +CREATE INDEX analysis_profiles_workspace_created_idx + ON analysis_profiles (workspace_id, created_at DESC, id DESC) + WHERE archived_at IS NULL; + +CREATE INDEX channels_workspace_created_idx + ON channels (workspace_id, created_at DESC, id DESC); + +CREATE INDEX channel_subscriptions_workspace_event_idx + ON channel_subscriptions (workspace_id, event_type, channel_id); + +CREATE INDEX conversations_workspace_created_idx + ON conversations (workspace_id, created_at DESC, id DESC); + +CREATE INDEX messages_workspace_occurred_idx + ON messages (workspace_id, occurred_at DESC, id DESC); + +CREATE INDEX messages_channel_occurred_idx + ON messages (workspace_id, channel_id, occurred_at DESC, id DESC); + +CREATE INDEX messages_conversation_occurred_idx + ON messages (workspace_id, conversation_id, occurred_at DESC, id DESC); diff --git a/mooncode.example.yaml b/mooncode.example.yaml index 3eb3e62..7022781 100644 --- a/mooncode.example.yaml +++ b/mooncode.example.yaml @@ -1,105 +1,47 @@ -service: - environment: development +http: + address: ":8080" + max_body_bytes: 1048576 + request_timeout: 30s database: - url: postgres://mooncode:mooncode@127.0.0.1:5432/mooncode?sslmode=disable + url: postgres://mooncode:change-me@127.0.0.1:5432/mooncode?sslmode=disable + migrate_on_start: true max_connections: 20 - min_connections: 2 - connect_timeout: 10s - health_interval: 5s - auto_migrate: true - -identity: - mode: oauth_subject_required - issuer: https://github.com - auth_url: http://auth.mooncode.localhost:3100 - gateway_token: mooncode-local-gateway-token-change-me -registration: - mode: invite_only - terms_version: "2026-07-01" - privacy_version: "2026-07-01" - invitation_ttl: 168h - create_personal_workspace: true - -secret_store: - # Public development placeholder. Generate a distinct 32-byte base64 key - # for every non-development deployment. - master_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +auth: + # Only trust these headers after a reverse proxy has stripped client values + # and copied Tinyauth's ForwardAuth response headers. + issuer: tinyauth + app_url: https://mooncode.example.com/app + logout_url: /api/user/logout + registration_mode: public + terms_version: "2026-07-30" + privacy_version: "2026-07-30" + trusted_proxy_cidrs: + - 10.0.0.2/32 + csrf_key: REPLACE_WITH_BASE64_32_BYTE_KEY + +secrets: + key: REPLACE_WITH_A_DISTINCT_BASE64_32_BYTE_KEY key_version: 1 -git: - repository_directory: /var/lib/mooncode/repositories - clone_timeout: 10m - depth: 1 - sync_cooldown: 30s - probe_cooldown: 15s - # Admin-controlled allow list for private GitLab networks; empty is safest. - allowed_private_cidrs: [] - -jobs: - poll_interval: 1s - lease_duration: 30s - renew_interval: 10s - retry_base_delay: 2s - -outbox: - poll_interval: 1s - batch_size: 100 - lease_duration: 30s - publish_timeout: 10s - retry_base_delay: 2s - retry_max_delay: 1m - -channels: - reconcile_interval: 10s - lease_duration: 30s - drain_timeout: 5s - retry_base_delay: 1s - retry_max_delay: 1m - mutation_cooldown: 1s - mutation_timeout: 30s - -security: - csrf: - # Public development placeholder, independent from the SecretStore key. - key: AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= - secure: false - trusted_origins: ["mooncode.localhost:3100"] - rate_limit: - per_ip: {rate: 30, burst: 60} - per_user: {rate: 20, burst: 40} - per_workspace: {rate: 50, burst: 100} - entry_ttl: 10m - max_entries: 10000 - -server: - address: ":8080" - read_header_timeout: 5s - read_timeout: 15s - write_timeout: 30s - idle_timeout: 60s - -admin: - # Keep loopback unless an isolated management network is enforced outside - # the process. - address: "127.0.0.1:9090" - read_header_timeout: 3s - read_timeout: 5s - write_timeout: 10s - idle_timeout: 30s - -log: - level: info - format: console - -observability: - tracing: - enabled: false - endpoint: "http://127.0.0.1:4318" - protocol: http/protobuf - sample_ratio: 1.0 - exporter_timeout: 5s - -shutdown: - timeout: 15s +repository: + root: /var/lib/mooncode/repositories + worktree_max_age: 24h + max_mirror_bytes: 10737418240 + max_per_workspace: 100 + +hatchet: + token: REPLACE_WITH_HATCHET_CLIENT_TOKEN + address: hatchet:7077 + namespace: mooncode + +analysis: + scc_path: scc + timeout: 15m + max_output_bytes: 16777216 + max_concurrent_per_workspace: 10 + +metrics: + # Worker-only health and Prometheus endpoint; keep on the private service network. + worker_address: ":9090" diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go new file mode 100644 index 0000000..e000f54 --- /dev/null +++ b/pkg/analyzer/analyzer.go @@ -0,0 +1,25 @@ +package analyzer + +import ( + "context" + "encoding/json" +) + +type Request struct { + Directory string + CommitSHA string + Parameters json.RawMessage +} + +type Result struct { + DimensionKey string + AnalyzerKey string + AnalyzerVersion string + Data json.RawMessage + RawArtifact json.RawMessage + Warnings []string +} + +type Analyzer interface { + Analyze(context.Context, Request) (Result, error) +} diff --git a/pkg/analyzer/errors.go b/pkg/analyzer/errors.go new file mode 100644 index 0000000..909b4f7 --- /dev/null +++ b/pkg/analyzer/errors.go @@ -0,0 +1,8 @@ +package analyzer + +import "errors" + +var ( + ErrOutputTooLarge = errors.New("analyzer output exceeded the allowed size") + ErrInvalidOutput = errors.New("analyzer output is invalid") +) diff --git a/pkg/analyzer/scc/analyzer.go b/pkg/analyzer/scc/analyzer.go new file mode 100644 index 0000000..2fb8cc4 --- /dev/null +++ b/pkg/analyzer/scc/analyzer.go @@ -0,0 +1,64 @@ +package scc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/fuchencong/mooncode/pkg/analyzer" +) + +func (a *Default) Analyze(ctx context.Context, request analyzer.Request) (analyzer.Result, error) { + command := exec.CommandContext(ctx, a.binary, "--format", "json", request.Directory) + command.Env = append([]string(nil), a.environment...) + configureProcess(command) + command.WaitDelay = 2 * time.Second + + stdout := newLimitedBuffer(a.maxOutputBytes) + stderr := newLimitedBuffer(a.maxDiagnosticBytes) + command.Stdout = stdout + command.Stderr = stderr + err := command.Run() + if stdout.Exceeded() { + return analyzer.Result{}, analyzer.ErrOutputTooLarge + } + if ctx.Err() != nil { + return analyzer.Result{}, fmt.Errorf("run scc: %w", ctx.Err()) + } + if err != nil { + diagnostic := strings.TrimSpace(stderr.String()) + if diagnostic == "" { + return analyzer.Result{}, fmt.Errorf("run scc: %w", err) + } + if stderr.Exceeded() { + diagnostic += " [truncated]" + } + + return analyzer.Result{}, fmt.Errorf("run scc: %w: %s", err, diagnostic) + } + if stderr.Exceeded() { + return analyzer.Result{}, errors.New("scc diagnostic output exceeded the allowed size") + } + + result, err := parse(stdout.Bytes()) + if err != nil { + return analyzer.Result{}, err + } + data, err := json.Marshal(result) + if err != nil { + return analyzer.Result{}, fmt.Errorf("%w: encode normalized scc result: %v", analyzer.ErrInvalidOutput, err) + } + + return analyzer.Result{ + DimensionKey: "code_scale", + AnalyzerKey: "scc", + AnalyzerVersion: Version, + Data: data, + RawArtifact: append(json.RawMessage(nil), result.Raw...), + Warnings: append([]string(nil), result.Warnings...), + }, nil +} diff --git a/pkg/analyzer/scc/analyzer_test.go b/pkg/analyzer/scc/analyzer_test.go new file mode 100644 index 0000000..fbe37ef --- /dev/null +++ b/pkg/analyzer/scc/analyzer_test.go @@ -0,0 +1,106 @@ +package scc + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/fuchencong/mooncode/pkg/analyzer" +) + +func TestAnalyzerAggregatesLanguages(t *testing.T) { + binary := filepath.Join(t.TempDir(), "scc") + script := `#!/bin/sh +printf '%s' '[{"Name":"Go","Lines":10,"Code":8,"Comment":1,"Blank":1,"Complexity":2,"Count":2,"Bytes":100},{"Name":"SQL","Lines":5,"Code":4,"Comment":0,"Blank":1,"Complexity":0,"Count":1,"Bytes":50}]' +` + if err := os.WriteFile(binary, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + result, err := New(WithBinary(binary)).Analyze(context.Background(), analyzer.Request{Directory: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + var data Result + if err := json.Unmarshal(result.Data, &data); err != nil { + t.Fatal(err) + } + if result.DimensionKey != "code_scale" || result.AnalyzerKey != "scc" || result.AnalyzerVersion != Version { + t.Fatalf("unexpected analyzer metadata: %#v", result) + } + if data.Summary.Files != 3 || data.Summary.Code != 12 || len(data.Languages) != 2 { + t.Fatalf("unexpected result: %#v", result) + } + if !json.Valid(result.RawArtifact) { + t.Fatalf("raw artifact is invalid JSON: %s", result.RawArtifact) + } +} + +func TestAnalyzerRejectsOversizedOutput(t *testing.T) { + binary := executable(t, "#!/bin/sh\nprintf '123456789'\n") + _, err := New(WithBinary(binary), WithMaxOutputBytes(8)).Analyze(context.Background(), analyzer.Request{Directory: t.TempDir()}) + if !errors.Is(err, analyzer.ErrOutputTooLarge) { + t.Fatalf("oversized output error = %v, want ErrOutputTooLarge", err) + } +} + +func TestAnalyzerCancellationKillsProcessGroup(t *testing.T) { + binary := executable(t, "#!/bin/sh\nsleep 30 &\nwait\n") + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + started := time.Now() + _, err := New(WithBinary(binary)).Analyze(ctx, analyzer.Request{Directory: t.TempDir()}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("cancelled analyzer error = %v, want context deadline", err) + } + if elapsed := time.Since(started); elapsed > 2*time.Second { + t.Fatalf("cancelled process group took %s to stop", elapsed) + } +} + +func TestAnalyzerRejectsInvalidMetricsAndTrailingJSON(t *testing.T) { + for _, test := range []struct { + name string + output string + }{ + {name: "negative metric", output: `[{"Name":"Go","Lines":-1}]`}, + {name: "trailing value", output: `[] {}`}, + } { + t.Run(test.name, func(t *testing.T) { + binary := executable(t, "#!/bin/sh\nprintf '%s' '"+test.output+"'\n") + if _, err := New(WithBinary(binary)).Analyze(context.Background(), analyzer.Request{Directory: t.TempDir()}); err == nil { + t.Fatal("invalid scc output was accepted") + } + }) + } +} + +func TestAnalyzerDoesNotInheritWorkerSecrets(t *testing.T) { + t.Setenv("MOONCODE_SECRETS_KEY", "must-not-reach-analyzer") + t.Setenv("MOONCODE_DATABASE_URL", "postgres://secret") + t.Setenv("MOONCODE_HATCHET_TOKEN", "hatchet-secret") + binary := executable(t, `#!/bin/sh +if env | grep -E '^(MOONCODE_SECRETS_KEY|MOONCODE_DATABASE_URL|MOONCODE_HATCHET_TOKEN)=' >/dev/null; then + echo 'worker secret reached analyzer' >&2 + exit 1 +fi +printf '[]' +`) + + if _, err := New(WithBinary(binary)).Analyze(context.Background(), analyzer.Request{Directory: t.TempDir()}); err != nil { + t.Fatal(err) + } +} + +func executable(t *testing.T, contents string) string { + t.Helper() + binary := filepath.Join(t.TempDir(), "scc") + if err := os.WriteFile(binary, []byte(contents), 0o700); err != nil { + t.Fatal(err) + } + + return binary +} diff --git a/pkg/analyzer/scc/limit.go b/pkg/analyzer/scc/limit.go new file mode 100644 index 0000000..c97de13 --- /dev/null +++ b/pkg/analyzer/scc/limit.go @@ -0,0 +1,43 @@ +package scc + +import ( + "bytes" + "errors" +) + +var errLimitExceeded = errors.New("output limit exceeded") + +type limitedBuffer struct { + buffer bytes.Buffer + limit int64 + exceeded bool +} + +func newLimitedBuffer(limit int64) *limitedBuffer { + return &limitedBuffer{limit: limit} +} + +func (b *limitedBuffer) Write(value []byte) (int, error) { + remaining := b.limit - int64(b.buffer.Len()) + if int64(len(value)) <= remaining { + return b.buffer.Write(value) + } + if remaining > 0 { + _, _ = b.buffer.Write(value[:remaining]) + } + b.exceeded = true + + return int(remaining), errLimitExceeded +} + +func (b *limitedBuffer) Bytes() []byte { + return b.buffer.Bytes() +} + +func (b *limitedBuffer) String() string { + return b.buffer.String() +} + +func (b *limitedBuffer) Exceeded() bool { + return b.exceeded +} diff --git a/pkg/analyzer/scc/model.go b/pkg/analyzer/scc/model.go new file mode 100644 index 0000000..7747e8b --- /dev/null +++ b/pkg/analyzer/scc/model.go @@ -0,0 +1,35 @@ +package scc + +import ( + "encoding/json" +) + +const Version = "scc-3.7.0-json-v1" + +type Summary struct { + Files int64 `json:"files"` + Lines int64 `json:"lines"` + Code int64 `json:"code"` + Comments int64 `json:"comments"` + Blanks int64 `json:"blanks"` + Bytes int64 `json:"bytes"` + Complexity int64 `json:"complexity"` +} + +type Language struct { + Name string `json:"name"` + Files int64 `json:"files"` + Lines int64 `json:"lines"` + Code int64 `json:"code"` + Comments int64 `json:"comments"` + Blanks int64 `json:"blanks"` + Bytes int64 `json:"bytes"` + Complexity int64 `json:"complexity"` +} + +type Result struct { + Summary Summary `json:"summary"` + Languages []Language `json:"languages"` + Warnings []string `json:"warnings"` + Raw json.RawMessage `json:"-"` +} diff --git a/pkg/analyzer/scc/options.go b/pkg/analyzer/scc/options.go new file mode 100644 index 0000000..6ce77ad --- /dev/null +++ b/pkg/analyzer/scc/options.go @@ -0,0 +1,48 @@ +package scc + +const ( + defaultMaxOutputBytes = 16 << 20 + defaultMaxDiagnosticBytes = 64 << 10 +) + +type Option func(*Default) + +type Default struct { + binary string + environment []string + maxOutputBytes int64 + maxDiagnosticBytes int64 +} + +func WithBinary(path string) Option { + return func(analyzer *Default) { + analyzer.binary = path + } +} + +func WithMaxOutputBytes(maxBytes int64) Option { + return func(analyzer *Default) { + if maxBytes > 0 { + analyzer.maxOutputBytes = maxBytes + } + } +} + +func WithEnvironment(environment ...string) Option { + return func(analyzer *Default) { + analyzer.environment = append([]string(nil), environment...) + } +} + +func New(options ...Option) *Default { + analyzer := &Default{ + binary: "scc", environment: []string{"LANG=C", "LC_ALL=C", "PATH=/usr/local/bin:/usr/bin:/bin", "TZ=UTC"}, + maxOutputBytes: defaultMaxOutputBytes, + maxDiagnosticBytes: defaultMaxDiagnosticBytes, + } + for _, option := range options { + option(analyzer) + } + + return analyzer +} diff --git a/pkg/analyzer/scc/parse.go b/pkg/analyzer/scc/parse.go new file mode 100644 index 0000000..e4c9095 --- /dev/null +++ b/pkg/analyzer/scc/parse.go @@ -0,0 +1,69 @@ +package scc + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/fuchencong/mooncode/pkg/analyzer" +) + +type rawLanguage struct { + Name string `json:"Name"` + Lines int64 `json:"Lines"` + Code int64 `json:"Code"` + Comment int64 `json:"Comment"` + Blank int64 `json:"Blank"` + Complexity int64 `json:"Complexity"` + Count int64 `json:"Count"` + Bytes int64 `json:"Bytes"` +} + +func parse(output []byte) (Result, error) { + var rows []rawLanguage + decoder := json.NewDecoder(bytes.NewReader(output)) + if err := decoder.Decode(&rows); err != nil { + return Result{}, fmt.Errorf("%w: decode JSON: %v", analyzer.ErrInvalidOutput, err) + } + if err := rejectTrailingJSON(decoder); err != nil { + return Result{}, err + } + + result := Result{Languages: make([]Language, 0, len(rows)), Warnings: []string{}, Raw: append(json.RawMessage(nil), output...)} + for _, row := range rows { + if strings.TrimSpace(row.Name) == "" || hasNegativeMetric(row) { + return Result{}, fmt.Errorf("%w: language metrics are invalid", analyzer.ErrInvalidOutput) + } + language := Language{Name: row.Name, Files: row.Count, Lines: row.Lines, Code: row.Code, Comments: row.Comment, Blanks: row.Blank, Bytes: row.Bytes, Complexity: row.Complexity} + result.Languages = append(result.Languages, language) + result.Summary.Files += language.Files + result.Summary.Lines += language.Lines + result.Summary.Code += language.Code + result.Summary.Comments += language.Comments + result.Summary.Blanks += language.Blanks + result.Summary.Bytes += language.Bytes + result.Summary.Complexity += language.Complexity + } + + return result, nil +} + +func rejectTrailingJSON(decoder *json.Decoder) error { + var trailing any + err := decoder.Decode(&trailing) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("%w: decode trailing JSON: %v", analyzer.ErrInvalidOutput, err) + } + + return fmt.Errorf("%w: multiple JSON values", analyzer.ErrInvalidOutput) +} + +func hasNegativeMetric(row rawLanguage) bool { + return row.Lines < 0 || row.Code < 0 || row.Comment < 0 || row.Blank < 0 || row.Complexity < 0 || row.Count < 0 || row.Bytes < 0 +} diff --git a/pkg/analyzer/scc/process_other.go b/pkg/analyzer/scc/process_other.go new file mode 100644 index 0000000..36dff14 --- /dev/null +++ b/pkg/analyzer/scc/process_other.go @@ -0,0 +1,7 @@ +//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris + +package scc + +import "os/exec" + +func configureProcess(*exec.Cmd) {} diff --git a/pkg/analyzer/scc/process_unix.go b/pkg/analyzer/scc/process_unix.go new file mode 100644 index 0000000..185462c --- /dev/null +++ b/pkg/analyzer/scc/process_unix.go @@ -0,0 +1,28 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package scc + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func configureProcess(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.Cancel = func() error { + if command.Process == nil { + return os.ErrProcessDone + } + if err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL); err != nil { + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + + return err + } + + return nil + } +} diff --git a/pkg/channel/base.go b/pkg/channel/base.go deleted file mode 100644 index 800c662..0000000 --- a/pkg/channel/base.go +++ /dev/null @@ -1,142 +0,0 @@ -package channel - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - "unicode/utf8" -) - -type BaseOption func(*BaseChannel) - -func WithGroupPolicy(policy GroupPolicy) BaseOption { - return func(base *BaseChannel) { base.groupPolicy = policy } -} -func WithMaxMessageBytes(limit int) BaseOption { - return func(base *BaseChannel) { - if limit > 0 { - base.maxMessageBytes = limit - } - } -} -func WithClock(clock func() time.Time) BaseOption { - return func(base *BaseChannel) { - if clock != nil { - base.clock = clock - } - } -} -func WithAttributeSchema(schema AttributeSchema) BaseOption { - return func(base *BaseChannel) { base.attributes = schema } -} -func WithInboundMiddlewares(middleware ...InboundMiddleware) BaseOption { - return func(base *BaseChannel) { base.middleware = append(base.middleware, middleware...) } -} - -type BaseChannel struct { - channelType string - accountID string - sink InboundSink - allowList map[string]struct{} - groupPolicy GroupPolicy - maxMessageBytes int - attributes AttributeSchema - clock func() time.Time - middleware []InboundMiddleware -} - -func NewBaseChannel(channelType string, config InstanceConfig, sink InboundSink, opts ...BaseOption) (*BaseChannel, error) { - if strings.TrimSpace(channelType) == "" || strings.TrimSpace(config.AccountID) == "" || sink == nil { - return nil, errors.New("channel type, account ID, and inbound sink are required") - } - base := &BaseChannel{channelType: strings.ToLower(channelType), accountID: config.AccountID, sink: sink, allowList: make(map[string]struct{}), groupPolicy: config.GroupPolicy, maxMessageBytes: 64 << 10, clock: time.Now} - for _, sender := range config.SenderAllowList { - sender = strings.TrimSpace(sender) - if sender != "" { - base.allowList[sender] = struct{}{} - } - } - for _, option := range opts { - if option != nil { - option(base) - } - } - return base, nil -} - -func (b *BaseChannel) Accept(ctx context.Context, message InboundMessage) (AcceptResult, error) { - message.AccountID, message.ChannelType = b.accountID, b.channelType - var result AcceptResult - handler := WrapInbound(func(ctx context.Context, message InboundMessage) error { - var err error - result, err = b.accept(ctx, message) - return err - }, b.middleware...) - return result, handler(ctx, message) -} - -func (b *BaseChannel) accept(ctx context.Context, message InboundMessage) (AcceptResult, error) { - message.ExternalEventID, message.ExternalMessageID = strings.TrimSpace(message.ExternalEventID), strings.TrimSpace(message.ExternalMessageID) - message.Conversation.ID, message.Conversation.Type = strings.TrimSpace(message.Conversation.ID), strings.ToLower(strings.TrimSpace(message.Conversation.Type)) - message.Sender.PlatformID = strings.TrimSpace(message.Sender.PlatformID) - if message.ExternalEventID == "" || message.ExternalMessageID == "" || message.Conversation.ID == "" || message.Sender.PlatformID == "" { - return "", ErrInvalidMessage - } - if _, all := b.allowList["*"]; !all { - if _, allowed := b.allowList[message.Sender.PlatformID]; !allowed { - return "", fmt.Errorf("%w: sender is not allowed", ErrRejected) - } - } - if message.Conversation.Type != "direct" { - prefixMatched := b.groupPolicy.Prefix != "" && strings.HasPrefix(strings.TrimSpace(message.Content.Text), b.groupPolicy.Prefix) - if b.groupPolicy.RequireMention && !message.Mentioned && !prefixMatched { - return "", fmt.Errorf("%w: group trigger did not match", ErrRejected) - } - if prefixMatched { - message.Content.Text = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(message.Content.Text), b.groupPolicy.Prefix)) - } - } - message.Content.Type, message.Content.Text = strings.ToLower(strings.TrimSpace(message.Content.Type)), strings.TrimSpace(message.Content.Text) - if !utf8.ValidString(message.Content.Text) || len(message.Content.Text) > b.maxMessageBytes { - return "", fmt.Errorf("%w: content size is invalid", ErrRejected) - } - message.Sender.CanonicalID = b.channelType + ":" + message.Sender.PlatformID - if message.OccurredAt.IsZero() { - message.OccurredAt = b.clock().UTC() - } - if message.ReceivedAt.IsZero() { - message.ReceivedAt = b.clock().UTC() - } - message.Attributes = filterAttributes(message.Attributes, b.attributes) - return b.sink.Accept(ctx, message) -} - -func filterAttributes(values map[string]string, schema AttributeSchema) map[string]string { - result := make(map[string]string) - allowed := make(map[string]AttributeField, len(schema.Fields)) - for _, field := range schema.Fields { - allowed[field.Key] = field - } - for key, value := range values { - field, ok := allowed[key] - if !ok || len(value) > field.MaxBytes { - continue - } - if len(field.Enum) > 0 { - valid := false - for _, candidate := range field.Enum { - if value == candidate { - valid = true - break - } - } - if !valid { - continue - } - } - result[key] = value - } - return result -} diff --git a/pkg/channel/channel.go b/pkg/channel/channel.go index 1c8dc78..b7b6ad4 100644 --- a/pkg/channel/channel.go +++ b/pkg/channel/channel.go @@ -1,144 +1,41 @@ -// Package channel defines the reusable inbound messaging extension boundary. -// It deliberately has no MoonCode workspace, database, or HTTP dependencies. package channel import ( "context" - "errors" "time" ) -var ( - ErrRejected = errors.New("channel: message rejected") - ErrInvalidMessage = errors.New("channel: invalid message") -) - -type Channel interface { - Type() string - Run(ctx context.Context) error +type Message struct { + Text string + TargetID string + TargetType string } -type InboundSink interface { - Accept(ctx context.Context, message InboundMessage) (AcceptResult, error) -} -type AcceptResult string - -const ( - Accepted AcceptResult = "accepted" - AlreadyAccepted AcceptResult = "already_accepted" -) -type InboundHandler func(context.Context, InboundMessage) error -type InboundMiddleware func(InboundHandler) InboundHandler - -// WrapInbound applies middleware in declaration order: the first middleware -// is the outermost boundary. -func WrapInbound(handler InboundHandler, middleware ...InboundMiddleware) InboundHandler { - for index := len(middleware) - 1; index >= 0; index-- { - if middleware[index] != nil { - handler = middleware[index](handler) - } - } - return handler +type Sender interface { + Send(context.Context, Message) error } -type ConversationRef struct { - ID string `json:"id"` - Type string `json:"type"` - TopicID string `json:"topicId,omitempty"` - SpaceID string `json:"spaceId,omitempty"` -} -type SenderInfo struct { - PlatformID string `json:"platformId"` - CanonicalID string `json:"canonicalId"` - Username string `json:"username,omitempty"` - DisplayName string `json:"displayName,omitempty"` -} -type AttachmentRef struct { - Type string `json:"type"` - ExternalID string `json:"externalId"` - Name string `json:"name,omitempty"` - Size int64 `json:"size,omitempty"` -} -type MessageContent struct { - Type string `json:"type"` - Text string `json:"text"` - Attachments []AttachmentRef `json:"attachments,omitempty"` -} type InboundMessage struct { - AccountID string - ChannelType string - ExternalEventID string - ExternalMessageID string - Conversation ConversationRef - Sender SenderInfo - Content MessageContent + ExternalID string + ConversationID string + ConversationType string + ConversationTitle string + SenderCanonicalID string + SenderDisplayName string + Text string Mentioned bool - ReplyToMessageID string OccurredAt time.Time - ReceivedAt time.Time - Attributes map[string]string } -type InstanceConfig struct { - AccountID string - Values map[string]any - Secrets map[string]string - SenderAllowList []string - GroupPolicy GroupPolicy -} - -type GroupPolicy struct { - RequireMention bool `json:"requireMention"` - Prefix string `json:"prefix"` -} -type ChannelOption func(*ChannelOptions) -type ChannelOptions struct{ InboundMiddleware []InboundMiddleware } - -func WithInboundMiddleware(middleware ...InboundMiddleware) ChannelOption { - return func(options *ChannelOptions) { - options.InboundMiddleware = append(options.InboundMiddleware, middleware...) - } -} +type Handler func(context.Context, InboundMessage) error -func ResolveChannelOptions(opts ...ChannelOption) ChannelOptions { - var options ChannelOptions - for _, option := range opts { - if option != nil { - option(&options) - } - } - return options +type Connection interface { + Sender + Start(context.Context, Handler) error + Close() error } type Factory interface { Type() string - Descriptor() Descriptor - Validate(config InstanceConfig) error - New(config InstanceConfig, sink InboundSink, opts ...ChannelOption) (Channel, error) -} - -type Descriptor struct { - Type string `json:"type"` - DisplayName string `json:"displayName"` - Icon string `json:"icon"` - Capabilities []string `json:"capabilities"` - Fields []ConfigField `json:"fields"` - AttributeSchema AttributeSchema `json:"attributeSchema"` - DocumentationURL string `json:"documentationUrl"` -} -type ConfigField struct { - Name string `json:"name"` - Type string `json:"type"` - Required bool `json:"required"` - Secret bool `json:"secret"` - Help string `json:"help"` - Enum []string `json:"enum,omitempty"` -} -type AttributeSchema struct { - Fields []AttributeField `json:"fields"` -} -type AttributeField struct { - Key string `json:"key"` - MaxBytes int `json:"maxBytes"` - Enum []string `json:"enum,omitempty"` + New(map[string]any, string) (Connection, error) } diff --git a/pkg/channel/channel_test.go b/pkg/channel/channel_test.go deleted file mode 100644 index c352e40..0000000 --- a/pkg/channel/channel_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package channel - -import ( - "context" - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" -) - -type captureSink struct{ message InboundMessage } - -func (s *captureSink) Accept(_ context.Context, message InboundMessage) (AcceptResult, error) { - s.message = message - return Accepted, nil -} -func TestBaseChannelPolicyAndNormalization(t *testing.T) { - sink := &captureSink{} - base, err := NewBaseChannel("feishu", InstanceConfig{AccountID: "account", SenderAllowList: []string{"sender"}, GroupPolicy: GroupPolicy{RequireMention: true, Prefix: "/moon"}}, sink, WithAttributeSchema(AttributeSchema{Fields: []AttributeField{{Key: "tenant", MaxBytes: 8}}})) - require.NoError(t, err) - result, err := base.Accept(context.Background(), InboundMessage{ExternalEventID: "event", ExternalMessageID: "message", Conversation: ConversationRef{ID: "chat", Type: "group"}, Sender: SenderInfo{PlatformID: "sender"}, Content: MessageContent{Type: "text", Text: "/moon hello"}, Attributes: map[string]string{"tenant": "one", "raw": "drop"}}) - require.NoError(t, err) - require.Equal(t, Accepted, result) - require.Equal(t, "hello", sink.message.Content.Text) - require.Equal(t, "feishu:sender", sink.message.Sender.CanonicalID) - require.Equal(t, map[string]string{"tenant": "one"}, sink.message.Attributes) -} -func TestBaseChannelEmptyAllowListRejects(t *testing.T) { - base, err := NewBaseChannel("dingtalk", InstanceConfig{AccountID: "a"}, &captureSink{}) - require.NoError(t, err) - _, err = base.Accept(context.Background(), InboundMessage{ExternalEventID: "e", ExternalMessageID: "m", Conversation: ConversationRef{ID: "c", Type: "direct"}, Sender: SenderInfo{PlatformID: "s"}, Content: MessageContent{Type: "text", Text: "hello"}}) - require.ErrorIs(t, err, ErrRejected) -} - -func TestInboundMiddlewareUsesDeclarationOrder(t *testing.T) { - var calls []string - middleware := func(name string) InboundMiddleware { - return func(next InboundHandler) InboundHandler { - return func(ctx context.Context, message InboundMessage) error { - calls = append(calls, name+":before") - err := next(ctx, message) - calls = append(calls, name+":after") - return err - } - } - } - handler := WrapInbound(func(context.Context, InboundMessage) error { - calls = append(calls, "handler") - return nil - }, middleware("outer"), middleware("inner")) - require.NoError(t, handler(t.Context(), InboundMessage{})) - require.Equal(t, []string{"outer:before", "inner:before", "handler", "inner:after", "outer:after"}, calls) -} - -func TestGroupPolicyUsesPublicJSONContract(t *testing.T) { - encoded, err := json.Marshal(GroupPolicy{RequireMention: true, Prefix: "/moon"}) - require.NoError(t, err) - require.JSONEq(t, `{"requireMention":true,"prefix":"/moon"}`, string(encoded)) -} diff --git a/pkg/channel/dingtalk/connection.go b/pkg/channel/dingtalk/connection.go new file mode 100644 index 0000000..e2d98b6 --- /dev/null +++ b/pkg/channel/dingtalk/connection.go @@ -0,0 +1,78 @@ +package dingtalk + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + + "github.com/fuchencong/mooncode/pkg/channel" +) + +const connectionTimeout = 30 * time.Second + +type connection struct { + *sender + + mu sync.Mutex + streamClient *client.StreamClient + cancel context.CancelFunc +} + +func (c *connection) Start(ctx context.Context, handler channel.Handler) error { + if handler == nil { + return errors.New("DingTalk inbound handler is required") + } + + c.mu.Lock() + if c.cancel != nil { + c.mu.Unlock() + return errors.New("DingTalk connection is already started") + } + runContext, cancel := context.WithCancel(ctx) + streamClient := client.NewStreamClient( + client.WithAppCredential(client.NewAppCredentialConfig(c.clientID, c.clientSecret)), + client.WithAutoReconnect(true), + ) + streamClient.RegisterChatBotCallbackRouter(func(eventContext context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { + message, ok := parseInbound(data, time.Now) + if !ok { + return nil, nil + } + + return nil, handler(eventContext, message) + }) + c.cancel = cancel + c.streamClient = streamClient + c.mu.Unlock() + + startContext, stopStarting := context.WithTimeout(runContext, connectionTimeout) + err := streamClient.Start(startContext) + stopStarting() + if err != nil { + _ = c.Close() + return fmt.Errorf("start DingTalk stream: %w", err) + } + + return nil +} + +func (c *connection) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.cancel != nil { + c.cancel() + c.cancel = nil + } + if c.streamClient != nil { + c.streamClient.Close() + c.streamClient = nil + } + + return nil +} diff --git a/pkg/channel/dingtalk/dingtalk.go b/pkg/channel/dingtalk/dingtalk.go index 9cabe6e..cf523df 100644 --- a/pkg/channel/dingtalk/dingtalk.go +++ b/pkg/channel/dingtalk/dingtalk.go @@ -1,98 +1,103 @@ -// Package dingtalk implements a channel.Factory with the official Stream SDK. package dingtalk import ( + "bytes" "context" + "encoding/json" "errors" + "net/http" "strings" "time" - "github.com/mooncode-ai/mooncode/pkg/channel" - "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" - "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + "github.com/fuchencong/mooncode/pkg/channel" ) -type Factory struct{} -type FactoryOption func(*Factory) +type Option func(*Factory) +type Factory struct{ client *http.Client } -func NewFactory(opts ...FactoryOption) *Factory { - factory := &Factory{} - for _, option := range opts { - if option != nil { - option(factory) - } - } - return factory -} -func (*Factory) Type() string { return "dingtalk" } -func (*Factory) Descriptor() channel.Descriptor { - return channel.Descriptor{Type: "dingtalk", DisplayName: "DingTalk", Icon: "message-square", Capabilities: []string{"receive"}, DocumentationURL: "https://open.dingtalk.com/document/isvapp/streaming-overview", Fields: []channel.ConfigField{{Name: "client_id", Type: "string", Required: true, Help: "Application Client ID"}, {Name: "client_secret", Type: "password", Required: true, Secret: true}}, AttributeSchema: channel.AttributeSchema{Fields: []channel.AttributeField{{Key: "corp_id", MaxBytes: 128}, {Key: "conversation_title", MaxBytes: 512}}}} +func WithHTTPClient(client *http.Client) Option { + return func(factory *Factory) { factory.client = client } } -func (*Factory) Validate(config channel.InstanceConfig) error { - if stringValue(config.Values, "client_id") == "" || config.Secrets["client_secret"] == "" { - return errors.New("dingtalk client_id and client_secret are required") +func New(options ...Option) *Factory { + value := &Factory{client: &http.Client{Timeout: 15 * time.Second}} + for _, option := range options { + option(value) } - return nil + return value } -func (factory *Factory) New(config channel.InstanceConfig, sink channel.InboundSink, opts ...channel.ChannelOption) (channel.Channel, error) { - if err := factory.Validate(config); err != nil { - return nil, err - } - options := channel.ResolveChannelOptions(opts...) - base, err := channel.NewBaseChannel(factory.Type(), config, sink, channel.WithAttributeSchema(factory.Descriptor().AttributeSchema), channel.WithInboundMiddlewares(options.InboundMiddleware...)) - if err != nil { - return nil, err +func (*Factory) Type() string { return "dingtalk" } +func (f *Factory) New(config map[string]any, clientSecret string) (channel.Connection, error) { + clientID, _ := config["client_id"].(string) + robotCode, _ := config["robot_code"].(string) + conversationID, _ := config["open_conversation_id"].(string) + if strings.TrimSpace(clientID) == "" || strings.TrimSpace(clientSecret) == "" || strings.TrimSpace(robotCode) == "" || strings.TrimSpace(conversationID) == "" { + return nil, errors.New("DingTalk client ID, client secret, robot code, and conversation ID are required") } - return &runtime{config: config, base: base}, nil + + return &connection{sender: &sender{client: f.client, clientID: clientID, clientSecret: clientSecret, robotCode: robotCode, conversationID: conversationID}}, nil } -type runtime struct { - config channel.InstanceConfig - base *channel.BaseChannel +type sender struct { + client *http.Client + clientID string + clientSecret string + robotCode string + conversationID string } -func (*runtime) Type() string { return "dingtalk" } -func (r *runtime) Run(ctx context.Context) error { - stream := client.NewStreamClient(client.WithAppCredential(client.NewAppCredentialConfig(stringValue(r.config.Values, "client_id"), r.config.Secrets["client_secret"]))) - stream.AutoReconnect = true - stream.RegisterChatBotCallbackRouter(r.receive) - if err := stream.Start(ctx); err != nil { +func (s *sender) Send(ctx context.Context, message channel.Message) error { + tokenBody, _ := json.Marshal(map[string]string{"appKey": s.clientID, "appSecret": s.clientSecret}) + tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.dingtalk.com/v1.0/oauth2/accessToken", bytes.NewReader(tokenBody)) + if err != nil { return err } - <-ctx.Done() - stream.AutoReconnect = false - stream.Close() - return nil -} -func (r *runtime) receive(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { - message, err := normalize(data) + tokenRequest.Header.Set("Content-Type", "application/json") + tokenResponse, err := s.client.Do(tokenRequest) if err != nil { - return nil, err + return err } - _, err = r.base.Accept(ctx, message) - if errors.Is(err, channel.ErrRejected) { - return []byte(""), nil + defer func() { _ = tokenResponse.Body.Close() }() + if tokenResponse.StatusCode/100 != 2 { + return channel.HTTPStatusError("DingTalk", "token", tokenResponse.StatusCode) } - return []byte(""), err -} -func normalize(data *chatbot.BotCallbackDataModel) (channel.InboundMessage, error) { - if data == nil { - return channel.InboundMessage{}, channel.ErrInvalidMessage + var token struct { + AccessToken string `json:"accessToken"` + } + if err := json.NewDecoder(tokenResponse.Body).Decode(&token); err != nil { + return err } - senderID := strings.TrimSpace(data.SenderStaffId) - if senderID == "" { - senderID = strings.TrimSpace(data.SenderId) + if token.AccessToken == "" { + return channel.Permanent(errors.New("DingTalk token response did not contain an access token")) } - conversationType := "group" - if data.ConversationType == "1" { - conversationType = "direct" + + parameter, _ := json.Marshal(map[string]string{"content": message.Text}) + endpoint := "https://api.dingtalk.com/v1.0/robot/groupMessages/send" + body, _ := json.Marshal(map[string]any{"robotCode": s.robotCode, "openConversationId": target(message.TargetID, s.conversationID), "msgKey": "sampleText", "msgParam": string(parameter)}) + if message.TargetType == "direct" && strings.TrimSpace(message.TargetID) != "" { + endpoint = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + body, _ = json.Marshal(map[string]any{"robotCode": s.robotCode, "userIds": []string{strings.TrimSpace(message.TargetID)}, "msgKey": "sampleText", "msgParam": string(parameter)}) } - return channel.InboundMessage{ExternalEventID: "message:" + data.MsgId, ExternalMessageID: data.MsgId, Conversation: channel.ConversationRef{ID: data.ConversationId, Type: conversationType}, Sender: channel.SenderInfo{PlatformID: senderID, DisplayName: data.SenderNick}, Content: channel.MessageContent{Type: data.Msgtype, Text: data.Text.Content}, Mentioned: data.IsInAtList, OccurredAt: time.UnixMilli(data.CreateAt).UTC(), Attributes: map[string]string{"corp_id": data.SenderCorpId, "conversation_title": data.ConversationTitle}}, nil -} -func stringValue(values map[string]any, key string) string { - value, _ := values[key].(string) - return strings.TrimSpace(value) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("x-acs-dingtalk-access-token", token.AccessToken) + request.Header.Set("Content-Type", "application/json") + response, err := s.client.Do(request) + if err != nil { + return err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode/100 != 2 { + return channel.HTTPStatusError("DingTalk", "message", response.StatusCode) + } + return nil } -var _ channel.Factory = (*Factory)(nil) -var _ channel.Channel = (*runtime)(nil) +func target(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + + return fallback +} diff --git a/pkg/channel/dingtalk/dingtalk_test.go b/pkg/channel/dingtalk/dingtalk_test.go index d57ce3f..1e3b521 100644 --- a/pkg/channel/dingtalk/dingtalk_test.go +++ b/pkg/channel/dingtalk/dingtalk_test.go @@ -1,25 +1,69 @@ package dingtalk import ( + "context" + "io" + "net/http" + "strings" "testing" - "time" - "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" - "github.com/stretchr/testify/require" + "github.com/fuchencong/mooncode/pkg/channel" ) -func TestNormalizeMessageFixture(t *testing.T) { - data := &chatbot.BotCallbackDataModel{ - MsgId: "msg-1", ConversationId: "conversation-1", ConversationType: "1", - SenderStaffId: "staff-1", SenderNick: "Moon", SenderCorpId: "corp-1", - Msgtype: "text", Text: chatbot.BotCallbackDataTextModel{Content: "hello moon"}, - CreateAt: time.Unix(1700000000, 0).UnixMilli(), +type roundTrip func(*http.Request) (*http.Response, error) + +func (fn roundTrip) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) } + +func TestSenderUsesAppCredentialAndConversation(t *testing.T) { + requests := 0 + client := &http.Client{Transport: roundTrip(func(request *http.Request) (*http.Response, error) { + requests++ + body, _ := io.ReadAll(request.Body) + if requests == 1 { + if !strings.Contains(string(body), `"appKey":"client-id"`) || !strings.Contains(string(body), `"appSecret":"secret"`) { + t.Fatalf("unexpected token body: %s", body) + } + return response(`{"accessToken":"access-token"}`), nil + } + if request.Header.Get("x-acs-dingtalk-access-token") != "access-token" || !strings.Contains(string(body), `"robotCode":"robot-code"`) || !strings.Contains(string(body), `"openConversationId":"conversation-id"`) { + t.Fatalf("unexpected message request: %s %s", request.Header, body) + } + return response(`{}`), nil + })} + sender, err := New(WithHTTPClient(client)).New(map[string]any{"client_id": "client-id", "robot_code": "robot-code", "open_conversation_id": "conversation-id"}, "secret") + if err != nil { + t.Fatal(err) + } + if err := sender.Send(context.Background(), channel.Message{Text: "done"}); err != nil { + t.Fatal(err) + } + if requests != 2 { + t.Fatalf("expected two requests, got %d", requests) + } +} + +func TestSenderUsesDirectEndpointForCommandReply(t *testing.T) { + requests := 0 + client := &http.Client{Transport: roundTrip(func(request *http.Request) (*http.Response, error) { + requests++ + if requests == 1 { + return response(`{"accessToken":"access-token"}`), nil + } + body, _ := io.ReadAll(request.Body) + if request.URL.Path != "/v1.0/robot/oToMessages/batchSend" || !strings.Contains(string(body), `"userIds":["staff-42"]`) { + t.Fatalf("direct reply used unexpected request: %s %s", request.URL, body) + } + return response(`{}`), nil + })} + sender, err := New(WithHTTPClient(client)).New(map[string]any{"client_id": "client-id", "robot_code": "robot-code", "open_conversation_id": "configured-conversation"}, "secret") + if err != nil { + t.Fatal(err) + } + if err := sender.Send(context.Background(), channel.Message{Text: "reply", TargetID: "staff-42", TargetType: "direct"}); err != nil { + t.Fatal(err) } - message, err := normalize(data) - require.NoError(t, err) - require.Equal(t, "message:msg-1", message.ExternalEventID) - require.Equal(t, "staff-1", message.Sender.PlatformID) - require.Equal(t, "direct", message.Conversation.Type) - require.Equal(t, "hello moon", message.Content.Text) - require.Equal(t, "corp-1", message.Attributes["corp_id"]) +} + +func response(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} } diff --git a/pkg/channel/dingtalk/inbound.go b/pkg/channel/dingtalk/inbound.go new file mode 100644 index 0000000..565e2df --- /dev/null +++ b/pkg/channel/dingtalk/inbound.go @@ -0,0 +1,67 @@ +package dingtalk + +import ( + "strings" + "time" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" + + "github.com/fuchencong/mooncode/pkg/channel" +) + +func parseInbound(data *chatbot.BotCallbackDataModel, now func() time.Time) (channel.InboundMessage, bool) { + if data == nil || data.Msgtype != "text" { + return channel.InboundMessage{}, false + } + text := strings.TrimSpace(data.Text.Content) + if text == "" { + if content, ok := data.Content.(map[string]any); ok { + text, _ = content["content"].(string) + text = strings.TrimSpace(text) + } + } + senderID := strings.TrimSpace(data.SenderStaffId) + if senderID == "" { + senderID = strings.TrimSpace(data.SenderId) + } + conversationID := strings.TrimSpace(data.ConversationId) + if conversationID == "" && data.ConversationType == "1" { + conversationID = senderID + } + if strings.TrimSpace(data.MsgId) == "" || text == "" || senderID == "" || conversationID == "" { + return channel.InboundMessage{}, false + } + if data.IsInAtList { + text = stripLeadingMentions(text) + } + + conversationType := "group" + if data.ConversationType == "1" { + conversationType = "direct" + } + occurredAt := now().UTC() + if data.CreateAt > 0 { + occurredAt = time.UnixMilli(data.CreateAt).UTC() + } + + return channel.InboundMessage{ + ExternalID: strings.TrimSpace(data.MsgId), + ConversationID: conversationID, + ConversationType: conversationType, + ConversationTitle: strings.TrimSpace(data.ConversationTitle), + SenderCanonicalID: "dingtalk:" + senderID, + SenderDisplayName: strings.TrimSpace(data.SenderNick), + Text: strings.TrimSpace(text), + Mentioned: data.IsInAtList, + OccurredAt: occurredAt, + }, true +} + +func stripLeadingMentions(value string) string { + fields := strings.Fields(value) + for len(fields) > 0 && strings.HasPrefix(fields[0], "@") { + fields = fields[1:] + } + + return strings.Join(fields, " ") +} diff --git a/pkg/channel/dingtalk/inbound_test.go b/pkg/channel/dingtalk/inbound_test.go new file mode 100644 index 0000000..c9ee386 --- /dev/null +++ b/pkg/channel/dingtalk/inbound_test.go @@ -0,0 +1,44 @@ +package dingtalk + +import ( + "testing" + "time" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" +) + +func TestParseInboundTextMessage(t *testing.T) { + data := &chatbot.BotCallbackDataModel{ + ConversationId: "cid", ConversationType: "2", ConversationTitle: "Engineering", + MsgId: "msg", Msgtype: "text", SenderStaffId: "staff", SenderNick: "Ada", + CreateAt: 1710000000123, IsInAtList: true, + Text: chatbot.BotCallbackDataTextModel{Content: "@MoonCode analyze this"}, + } + + message, ok := parseInbound(data, func() time.Time { return time.Time{} }) + if !ok { + t.Fatal("expected message to be parsed") + } + if message.ExternalID != "msg" || message.ConversationID != "cid" || message.ConversationType != "group" { + t.Fatalf("unexpected message identity: %+v", message) + } + if message.SenderCanonicalID != "dingtalk:staff" || message.SenderDisplayName != "Ada" || !message.Mentioned || message.Text != "analyze this" { + t.Fatalf("unexpected normalized message: %+v", message) + } + if want := time.UnixMilli(1710000000123).UTC(); !message.OccurredAt.Equal(want) { + t.Fatalf("occurred at = %s, want %s", message.OccurredAt, want) + } +} + +func TestParseInboundDirectConversationFallback(t *testing.T) { + now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC) + data := &chatbot.BotCallbackDataModel{ + ConversationType: "1", MsgId: "msg", Msgtype: "text", SenderId: "sender", + Text: chatbot.BotCallbackDataTextModel{Content: "hello"}, + } + + message, ok := parseInbound(data, func() time.Time { return now }) + if !ok || message.ConversationID != "sender" || message.ConversationType != "direct" || !message.OccurredAt.Equal(now) { + t.Fatalf("unexpected direct message: %+v, parsed=%v", message, ok) + } +} diff --git a/pkg/channel/errors.go b/pkg/channel/errors.go new file mode 100644 index 0000000..2f296a5 --- /dev/null +++ b/pkg/channel/errors.go @@ -0,0 +1,42 @@ +package channel + +import ( + "errors" + "fmt" + "net/http" +) + +type permanentError struct { + cause error +} + +func Permanent(cause error) error { + if cause == nil || IsPermanent(cause) { + return cause + } + + return &permanentError{cause: cause} +} + +func (e *permanentError) Error() string { + return e.cause.Error() +} + +func (e *permanentError) Unwrap() error { + return e.cause +} + +func IsPermanent(err error) bool { + var target *permanentError + + return errors.As(err, &target) +} + +func HTTPStatusError(platform, endpoint string, status int) error { + err := fmt.Errorf("%s %s endpoint returned HTTP %d", platform, endpoint, status) + if status >= http.StatusBadRequest && status < http.StatusInternalServerError && status != http.StatusRequestTimeout && status != http.StatusTooManyRequests { + return Permanent(err) + } + + return err +} diff --git a/pkg/channel/errors_test.go b/pkg/channel/errors_test.go new file mode 100644 index 0000000..15cc0c4 --- /dev/null +++ b/pkg/channel/errors_test.go @@ -0,0 +1,20 @@ +package channel + +import "testing" + +func TestHTTPStatusErrorClassification(t *testing.T) { + for _, test := range []struct { + status int + permanent bool + }{ + {status: 400, permanent: true}, + {status: 401, permanent: true}, + {status: 408, permanent: false}, + {status: 429, permanent: false}, + {status: 500, permanent: false}, + } { + if actual := IsPermanent(HTTPStatusError("test", "message", test.status)); actual != test.permanent { + t.Fatalf("HTTP %d permanent = %v, want %v", test.status, actual, test.permanent) + } + } +} diff --git a/pkg/channel/fake/fake.go b/pkg/channel/fake/fake.go deleted file mode 100644 index 70b59be..0000000 --- a/pkg/channel/fake/fake.go +++ /dev/null @@ -1,92 +0,0 @@ -// Package fake provides a deterministic Channel adapter for development and -// end-to-end tests. Applications must register it explicitly. -package fake - -import ( - "context" - "errors" - "strings" - "time" - - "github.com/mooncode-ai/mooncode/pkg/channel" -) - -type Factory struct{} - -func NewFactory() *Factory { return &Factory{} } -func (*Factory) Type() string { return "fake" } -func (*Factory) Descriptor() channel.Descriptor { - return channel.Descriptor{ - Type: "fake", DisplayName: "Development fixture", Icon: "test-tube", - Capabilities: []string{"receive"}, - Fields: []channel.ConfigField{ - {Name: "message", Type: "string", Required: false, Help: "Text emitted when the runtime starts."}, - {Name: "event_id", Type: "string", Required: false, Help: "Stable event ID used to verify deduplication."}, - }, - AttributeSchema: channel.AttributeSchema{Fields: []channel.AttributeField{{Key: "fixture", MaxBytes: 16, Enum: []string{"compose"}}}}, - } -} -func (*Factory) Validate(config channel.InstanceConfig) error { - if strings.TrimSpace(config.AccountID) == "" { - return errors.New("fake channel account ID is required") - } - if len(config.SenderAllowList) == 0 { - return errors.New("fake channel sender allow list is required") - } - for _, key := range []string{"message", "event_id"} { - if value, ok := config.Values[key]; ok { - if _, valid := value.(string); !valid { - return errors.New("fake channel values must be strings") - } - } - } - return nil -} -func (factory *Factory) New(config channel.InstanceConfig, sink channel.InboundSink, opts ...channel.ChannelOption) (channel.Channel, error) { - if err := factory.Validate(config); err != nil { - return nil, err - } - options := channel.ResolveChannelOptions(opts...) - base, err := channel.NewBaseChannel(factory.Type(), config, sink, - channel.WithAttributeSchema(factory.Descriptor().AttributeSchema), - channel.WithInboundMiddlewares(options.InboundMiddleware...), - ) - if err != nil { - return nil, err - } - message := stringValue(config.Values, "message", "MoonCode Compose message") - eventID := stringValue(config.Values, "event_id", "mooncode-compose-event") - return &runtime{base: base, message: message, eventID: eventID}, nil -} - -type runtime struct { - base *channel.BaseChannel - message string - eventID string -} - -func (*runtime) Type() string { return "fake" } -func (instance *runtime) Run(ctx context.Context) error { - _, err := instance.base.Accept(ctx, channel.InboundMessage{ - ExternalEventID: instance.eventID, ExternalMessageID: instance.eventID, - Conversation: channel.ConversationRef{ID: "mooncode-compose-conversation", Type: "direct"}, - Sender: channel.SenderInfo{PlatformID: "mooncode-compose-sender", DisplayName: "Compose Fixture"}, - Content: channel.MessageContent{Type: "text", Text: instance.message}, - OccurredAt: time.Now().UTC(), Attributes: map[string]string{"fixture": "compose", "discarded": "unsafe"}, - }) - if err != nil { - return err - } - <-ctx.Done() - return nil -} - -func stringValue(values map[string]any, key, fallback string) string { - if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - return fallback -} - -var _ channel.Factory = (*Factory)(nil) -var _ channel.Channel = (*runtime)(nil) diff --git a/pkg/channel/fake/fake_test.go b/pkg/channel/fake/fake_test.go deleted file mode 100644 index 4387970..0000000 --- a/pkg/channel/fake/fake_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package fake - -import ( - "context" - "testing" - - "github.com/mooncode-ai/mooncode/pkg/channel" - "github.com/stretchr/testify/require" -) - -func TestRuntimeEmitsNormalizedDeterministicMessage(t *testing.T) { - sink := &recordingSink{accepted: make(chan channel.InboundMessage, 1)} - runtime, err := NewFactory().New(channel.InstanceConfig{ - AccountID: "fixture", Values: map[string]any{"message": "hello", "event_id": "event-one"}, SenderAllowList: []string{"*"}, - }, sink) - require.NoError(t, err) - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { done <- runtime.Run(ctx) }() - message := <-sink.accepted - require.Equal(t, "fake", message.ChannelType) - require.Equal(t, "fake:mooncode-compose-sender", message.Sender.CanonicalID) - require.Equal(t, map[string]string{"fixture": "compose"}, message.Attributes) - cancel() - require.NoError(t, <-done) -} - -type recordingSink struct{ accepted chan channel.InboundMessage } - -func (sink *recordingSink) Accept(_ context.Context, message channel.InboundMessage) (channel.AcceptResult, error) { - sink.accepted <- message - return channel.Accepted, nil -} diff --git a/pkg/channel/feishu/connection.go b/pkg/channel/feishu/connection.go new file mode 100644 index 0000000..7b7faa6 --- /dev/null +++ b/pkg/channel/feishu/connection.go @@ -0,0 +1,156 @@ +package feishu + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + larkws "github.com/larksuite/oapi-sdk-go/v3/ws" + + "github.com/fuchencong/mooncode/pkg/channel" +) + +const connectionTimeout = 30 * time.Second + +type websocket interface { + Start(context.Context) error +} + +type connection struct { + *sender + + mu sync.Mutex + cancel context.CancelFunc +} + +func (c *connection) Start(ctx context.Context, handler channel.Handler) error { + if handler == nil { + return errors.New("feishu inbound handler is required") + } + + c.mu.Lock() + if c.cancel != nil { + c.mu.Unlock() + return errors.New("feishu connection is already started") + } + runContext, cancel := context.WithCancel(ctx) + c.cancel = cancel + c.mu.Unlock() + + botOpenID, _ := c.loadBotOpenID(runContext) + dispatcher := larkdispatcher.NewEventDispatcher("", ""). + OnP2MessageReceiveV1(func(eventContext context.Context, event *larkim.P2MessageReceiveV1) error { + message, ok := parseInbound(event, botOpenID, time.Now) + if !ok { + return nil + } + + return handler(eventContext, message) + }) + + ready := make(chan struct{}, 1) + failed := make(chan error, 1) + domain := lark.FeishuBaseUrl + if c.host == lark.LarkBaseUrl { + domain = lark.LarkBaseUrl + } + client := larkws.NewClient( + c.appID, + c.appSecret, + larkws.WithEventHandler(dispatcher), + larkws.WithDomain(domain), + larkws.WithOnReady(func() { signalReady(ready) }), + larkws.WithOnError(func(err error) { signalError(failed, err) }), + ) + go func(client websocket) { + if err := client.Start(runContext); err != nil { + signalError(failed, err) + } + }(client) + + timer := time.NewTimer(connectionTimeout) + defer timer.Stop() + select { + case <-ready: + return nil + case err := <-failed: + _ = c.Close() + return fmt.Errorf("start feishu websocket: %w", err) + case <-timer.C: + _ = c.Close() + return errors.New("start feishu websocket: timed out") + case <-ctx.Done(): + _ = c.Close() + return ctx.Err() + } +} + +func (c *connection) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.cancel != nil { + c.cancel() + c.cancel = nil + } + + return nil +} + +func (c *connection) loadBotOpenID(ctx context.Context) (string, error) { + token, err := c.tenantAccessToken(ctx) + if err != nil { + return "", err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.host+"/open-apis/bot/v3/info", nil) + if err != nil { + return "", err + } + request.Header.Set("Authorization", "Bearer "+token) + response, err := c.client.Do(request) + if err != nil { + return "", err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode/100 != 2 { + return "", fmt.Errorf("feishu bot endpoint returned HTTP %d", response.StatusCode) + } + var result struct { + Code int `json:"code"` + Bot struct { + OpenID string `json:"open_id"` + } `json:"bot"` + } + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + return "", err + } + if result.Code != 0 || result.Bot.OpenID == "" { + return "", fmt.Errorf("feishu bot request failed: code %d", result.Code) + } + + return result.Bot.OpenID, nil +} + +func signalReady(destination chan<- struct{}) { + select { + case destination <- struct{}{}: + default: + } +} + +func signalError(destination chan<- error, err error) { + if err == nil { + return + } + select { + case destination <- err: + default: + } +} diff --git a/pkg/channel/feishu/feishu.go b/pkg/channel/feishu/feishu.go index d43ab27..59b73d1 100644 --- a/pkg/channel/feishu/feishu.go +++ b/pkg/channel/feishu/feishu.go @@ -1,169 +1,114 @@ -// Package feishu implements a channel.Factory with the official Lark SDK. package feishu import ( + "bytes" "context" "encoding/json" "errors" - "strconv" + "fmt" + "net/http" "strings" "time" - lark "github.com/larksuite/oapi-sdk-go/v3" - "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" - larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" - larkws "github.com/larksuite/oapi-sdk-go/v3/ws" - "github.com/mooncode-ai/mooncode/pkg/channel" + "github.com/fuchencong/mooncode/pkg/channel" ) -type Factory struct{} -type FactoryOption func(*Factory) +type Option func(*Factory) +type Factory struct{ client *http.Client } -func NewFactory(opts ...FactoryOption) *Factory { - factory := &Factory{} - for _, option := range opts { - if option != nil { - option(factory) - } +func WithHTTPClient(client *http.Client) Option { + return func(factory *Factory) { factory.client = client } +} +func New(options ...Option) *Factory { + value := &Factory{client: &http.Client{Timeout: 15 * time.Second}} + for _, option := range options { + option(value) } - return factory + return value } func (*Factory) Type() string { return "feishu" } -func (*Factory) Descriptor() channel.Descriptor { - return channel.Descriptor{Type: "feishu", DisplayName: "Feishu / Lark", Icon: "message-square", Capabilities: []string{"receive"}, DocumentationURL: "https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case", Fields: []channel.ConfigField{{Name: "app_id", Type: "string", Required: true, Help: "Application ID"}, {Name: "app_secret", Type: "password", Required: true, Secret: true}, {Name: "verification_token", Type: "password", Secret: true}, {Name: "encrypt_key", Type: "password", Secret: true}, {Name: "is_lark", Type: "boolean"}}, AttributeSchema: channel.AttributeSchema{Fields: []channel.AttributeField{{Key: "tenant_key", MaxBytes: 128}}}} -} -func (*Factory) Validate(config channel.InstanceConfig) error { - if stringValue(config.Values, "app_id") == "" || config.Secrets["app_secret"] == "" { - return errors.New("feishu app_id and app_secret are required") +func (f *Factory) New(config map[string]any, appSecret string) (channel.Connection, error) { + appID, _ := config["app_id"].(string) + receiveID, _ := config["receive_id"].(string) + receiveIDType, _ := config["receive_id_type"].(string) + if receiveIDType == "" { + receiveIDType = "chat_id" } - return nil -} -func (factory *Factory) New(config channel.InstanceConfig, sink channel.InboundSink, opts ...channel.ChannelOption) (channel.Channel, error) { - if err := factory.Validate(config); err != nil { - return nil, err + if strings.TrimSpace(appID) == "" || strings.TrimSpace(appSecret) == "" || strings.TrimSpace(receiveID) == "" { + return nil, errors.New("feishu app ID, app secret, and receive ID are required") } - options := channel.ResolveChannelOptions(opts...) - base, err := channel.NewBaseChannel(factory.Type(), config, sink, channel.WithAttributeSchema(factory.Descriptor().AttributeSchema), channel.WithInboundMiddlewares(options.InboundMiddleware...)) - if err != nil { - return nil, err + host := "https://open.feishu.cn" + if isLark, _ := config["is_lark"].(bool); isLark { + host = "https://open.larksuite.com" } - return &runtime{config: config, base: base}, nil + return &connection{sender: &sender{client: f.client, host: host, appID: appID, appSecret: appSecret, receiveID: receiveID, receiveIDType: receiveIDType}}, nil } -type runtime struct { - config channel.InstanceConfig - base *channel.BaseChannel +type sender struct { + client *http.Client + host string + appID string + appSecret string + receiveID string + receiveIDType string } -func (*runtime) Type() string { return "feishu" } -func (r *runtime) Run(ctx context.Context) error { - dispatch := dispatcher.NewEventDispatcher(r.config.Secrets["verification_token"], r.config.Secrets["encrypt_key"]).OnP2MessageReceiveV1(r.receive) - options := []larkws.ClientOption{larkws.WithEventHandler(dispatch), larkws.WithAutoReconnect(true)} - if boolValue(r.config.Values, "is_lark") { - options = append(options, larkws.WithDomain(lark.LarkBaseUrl)) +func (s *sender) Send(ctx context.Context, message channel.Message) error { + token, err := s.tenantAccessToken(ctx) + if err != nil { + return err } - client := larkws.NewClient(stringValue(r.config.Values, "app_id"), r.config.Secrets["app_secret"], options...) - result := make(chan error, 1) - // The official v3.9.9 SDK enters a permanent select after connecting, and - // Close does not unblock Start. Keep the unavoidable SDK goroutine isolated - // from MoonCode's supervisor until upstream provides a waitable API. - go func() { result <- client.Start(ctx) }() - select { - case err := <-result: + + receiveID, receiveIDType := s.receiveID, s.receiveIDType + if strings.TrimSpace(message.TargetID) != "" { + receiveID, receiveIDType = strings.TrimSpace(message.TargetID), "chat_id" + } + content, _ := json.Marshal(map[string]string{"text": message.Text}) + body, _ := json.Marshal(map[string]any{"receive_id": receiveID, "msg_type": "text", "content": string(content)}) + endpoint := fmt.Sprintf("%s/open-apis/im/v1/messages?receive_id_type=%s", s.host, receiveIDType) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { return err - case <-ctx.Done(): - client.Close() - return nil } -} -func (r *runtime) receive(ctx context.Context, event *larkim.P2MessageReceiveV1) error { - message, err := normalize(event) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Content-Type", "application/json") + response, err := s.client.Do(request) if err != nil { return err } - _, err = r.base.Accept(ctx, message) - if errors.Is(err, channel.ErrRejected) { - return nil + defer func() { _ = response.Body.Close() }() + if response.StatusCode/100 != 2 { + return channel.HTTPStatusError("feishu", "message", response.StatusCode) } - return err + return nil } -func normalize(event *larkim.P2MessageReceiveV1) (channel.InboundMessage, error) { - if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil { - return channel.InboundMessage{}, channel.ErrInvalidMessage - } - source := event.Event.Message - messageID := pointer(source.MessageId) - senderID := pointer(event.Event.Sender.SenderId.OpenId) - if senderID == "" { - senderID = pointer(event.Event.Sender.SenderId.UserId) - } - if senderID == "" { - senderID = pointer(event.Event.Sender.SenderId.UnionId) - } - eventID, tenant := "", pointer(event.Event.Sender.TenantKey) - if event.EventV2Base != nil && event.EventV2Base.Header != nil { - eventID, tenant = event.EventV2Base.Header.EventID, event.EventV2Base.Header.TenantKey - } - if eventID == "" { - eventID = "message:" + messageID - } - conversationType := "group" - if pointer(source.ChatType) == "p2p" { - conversationType = "direct" +func (s *sender) tenantAccessToken(ctx context.Context) (string, error) { + tokenBody, _ := json.Marshal(map[string]string{"app_id": s.appID, "app_secret": s.appSecret}) + tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, s.host+"/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(tokenBody)) + if err != nil { + return "", err } - occurredAt := time.Now().UTC() - if milliseconds, err := strconv.ParseInt(pointer(source.CreateTime), 10, 64); err == nil { - occurredAt = time.UnixMilli(milliseconds).UTC() + tokenRequest.Header.Set("Content-Type", "application/json") + tokenResponse, err := s.client.Do(tokenRequest) + if err != nil { + return "", err } - contentType, text := pointer(source.MessageType), extractText(pointer(source.Content)) - if contentType == "" { - contentType = "unknown" + defer func() { _ = tokenResponse.Body.Close() }() + if tokenResponse.StatusCode/100 != 2 { + return "", channel.HTTPStatusError("feishu", "token", tokenResponse.StatusCode) } - return channel.InboundMessage{ExternalEventID: eventID, ExternalMessageID: messageID, Conversation: channel.ConversationRef{ID: pointer(source.ChatId), Type: conversationType, TopicID: pointer(source.ThreadId)}, Sender: channel.SenderInfo{PlatformID: senderID}, Content: channel.MessageContent{Type: contentType, Text: text}, Mentioned: len(source.Mentions) > 0, ReplyToMessageID: pointer(source.ParentId), OccurredAt: occurredAt, Attributes: map[string]string{"tenant_key": tenant}}, nil -} -func extractText(raw string) string { - var value any - if json.Unmarshal([]byte(raw), &value) != nil { - return "" + var token struct { + Code int `json:"code"` + Message string `json:"msg"` + TenantAccessToken string `json:"tenant_access_token"` } - parts := make([]string, 0) - collectStrings(value, &parts) - return strings.TrimSpace(strings.Join(parts, " ")) -} -func collectStrings(value any, parts *[]string) { - switch typed := value.(type) { - case string: - if strings.TrimSpace(typed) != "" { - *parts = append(*parts, typed) - } - case []any: - for _, item := range typed { - collectStrings(item, parts) - } - case map[string]any: - if text, ok := typed["text"].(string); ok { - collectStrings(text, parts) - return - } - for key, item := range typed { - if key != "tag" && key != "style" { - collectStrings(item, parts) - } - } + if err := json.NewDecoder(tokenResponse.Body).Decode(&token); err != nil { + return "", err } -} -func pointer(value *string) string { - if value == nil { - return "" + if token.Code != 0 || token.TenantAccessToken == "" { + return "", channel.Permanent(fmt.Errorf("feishu token request failed: code %d", token.Code)) } - return *value -} -func stringValue(values map[string]any, key string) string { - value, _ := values[key].(string) - return strings.TrimSpace(value) -} -func boolValue(values map[string]any, key string) bool { value, _ := values[key].(bool); return value } -var _ channel.Factory = (*Factory)(nil) -var _ channel.Channel = (*runtime)(nil) + return token.TenantAccessToken, nil +} diff --git a/pkg/channel/feishu/feishu_test.go b/pkg/channel/feishu/feishu_test.go index 0e635d1..49fb4bc 100644 --- a/pkg/channel/feishu/feishu_test.go +++ b/pkg/channel/feishu/feishu_test.go @@ -1,22 +1,69 @@ package feishu import ( - "encoding/json" + "context" + "io" + "net/http" + "strings" "testing" - larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" - "github.com/stretchr/testify/require" + "github.com/fuchencong/mooncode/pkg/channel" ) -func TestNormalizeMessageFixture(t *testing.T) { - fixture := []byte(`{"schema":"2.0","header":{"event_id":"evt-1","tenant_key":"tenant-1"},"event":{"sender":{"sender_id":{"open_id":"ou-1"},"tenant_key":"tenant-1"},"message":{"message_id":"om-1","chat_id":"oc-1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello moon\"}","create_time":"1700000000000"}}}`) - var event larkim.P2MessageReceiveV1 - require.NoError(t, json.Unmarshal(fixture, &event)) - message, err := normalize(&event) - require.NoError(t, err) - require.Equal(t, "evt-1", message.ExternalEventID) - require.Equal(t, "ou-1", message.Sender.PlatformID) - require.Equal(t, "direct", message.Conversation.Type) - require.Equal(t, "hello moon", message.Content.Text) - require.Equal(t, "tenant-1", message.Attributes["tenant_key"]) +type roundTrip func(*http.Request) (*http.Response, error) + +func (fn roundTrip) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) } + +func TestSenderUsesAppCredentialAndChatID(t *testing.T) { + requests := 0 + client := &http.Client{Transport: roundTrip(func(request *http.Request) (*http.Response, error) { + requests++ + body, _ := io.ReadAll(request.Body) + if requests == 1 { + if !strings.Contains(string(body), `"app_id":"app-id"`) || !strings.Contains(string(body), `"app_secret":"secret"`) { + t.Fatalf("unexpected token body: %s", body) + } + return response(`{"code":0,"tenant_access_token":"tenant-token"}`), nil + } + if request.Header.Get("Authorization") != "Bearer tenant-token" || !strings.Contains(request.URL.RawQuery, "receive_id_type=chat_id") || !strings.Contains(string(body), `"receive_id":"chat-id"`) { + t.Fatalf("unexpected message request: %s %s %s", request.URL, request.Header, body) + } + return response(`{"code":0}`), nil + })} + sender, err := New(WithHTTPClient(client)).New(map[string]any{"app_id": "app-id", "receive_id": "chat-id"}, "secret") + if err != nil { + t.Fatal(err) + } + if err := sender.Send(context.Background(), channel.Message{Text: "done"}); err != nil { + t.Fatal(err) + } + if requests != 2 { + t.Fatalf("expected two requests, got %d", requests) + } +} + +func TestSenderOverridesConfiguredTargetForCommandReply(t *testing.T) { + requests := 0 + client := &http.Client{Transport: roundTrip(func(request *http.Request) (*http.Response, error) { + requests++ + if requests == 1 { + return response(`{"code":0,"tenant_access_token":"tenant-token"}`), nil + } + body, _ := io.ReadAll(request.Body) + if !strings.Contains(string(body), `"receive_id":"inbound-chat"`) || !strings.Contains(request.URL.RawQuery, "receive_id_type=chat_id") { + t.Fatalf("command reply did not target inbound chat: %s %s", request.URL, body) + } + return response(`{"code":0}`), nil + })} + sender, err := New(WithHTTPClient(client)).New(map[string]any{"app_id": "app-id", "receive_id": "configured-chat"}, "secret") + if err != nil { + t.Fatal(err) + } + if err := sender.Send(context.Background(), channel.Message{Text: "reply", TargetID: "inbound-chat", TargetType: "group"}); err != nil { + t.Fatal(err) + } +} + +func response(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} } diff --git a/pkg/channel/feishu/inbound.go b/pkg/channel/feishu/inbound.go new file mode 100644 index 0000000..b7851f5 --- /dev/null +++ b/pkg/channel/feishu/inbound.go @@ -0,0 +1,94 @@ +package feishu + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/fuchencong/mooncode/pkg/channel" +) + +func parseInbound(event *larkim.P2MessageReceiveV1, botOpenID string, now func() time.Time) (channel.InboundMessage, bool) { + if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil { + return channel.InboundMessage{}, false + } + message := event.Event.Message + if stringValue(message.MessageType) != "text" { + return channel.InboundMessage{}, false + } + + var content struct { + Text string `json:"text"` + } + if err := json.Unmarshal([]byte(stringValue(message.Content)), &content); err != nil { + return channel.InboundMessage{}, false + } + externalID := strings.TrimSpace(stringValue(message.MessageId)) + conversationID := strings.TrimSpace(stringValue(message.ChatId)) + senderID := feishuSenderID(event.Event.Sender) + if externalID == "" || conversationID == "" || senderID == "" || strings.TrimSpace(content.Text) == "" { + return channel.InboundMessage{}, false + } + + mentioned := false + text := content.Text + for _, mention := range message.Mentions { + if mention == nil || mention.Id == nil || mention.Id.OpenId == nil || *mention.Id.OpenId != botOpenID || botOpenID == "" { + continue + } + mentioned = true + text = strings.ReplaceAll(text, stringValue(mention.Key), "") + } + + conversationType := "group" + if stringValue(message.ChatType) == "p2p" { + conversationType = "direct" + } + + return channel.InboundMessage{ + ExternalID: externalID, + ConversationID: conversationID, + ConversationType: conversationType, + SenderCanonicalID: "feishu:" + senderID, + Text: strings.TrimSpace(text), + Mentioned: mentioned, + OccurredAt: milliseconds(stringValue(message.CreateTime), now), + }, true +} + +func feishuSenderID(sender *larkim.EventSender) string { + if sender == nil || sender.SenderId == nil { + return "" + } + if sender.SenderId.OpenId != nil && strings.TrimSpace(*sender.SenderId.OpenId) != "" { + return strings.TrimSpace(*sender.SenderId.OpenId) + } + if sender.SenderId.UserId != nil && strings.TrimSpace(*sender.SenderId.UserId) != "" { + return strings.TrimSpace(*sender.SenderId.UserId) + } + if sender.SenderId.UnionId != nil { + return strings.TrimSpace(*sender.SenderId.UnionId) + } + + return "" +} + +func milliseconds(value string, now func() time.Time) time.Time { + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return now().UTC() + } + + return time.UnixMilli(parsed).UTC() +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + + return *value +} diff --git a/pkg/channel/feishu/inbound_test.go b/pkg/channel/feishu/inbound_test.go new file mode 100644 index 0000000..3ee3bbb --- /dev/null +++ b/pkg/channel/feishu/inbound_test.go @@ -0,0 +1,54 @@ +package feishu + +import ( + "testing" + "time" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +func TestParseInboundTextMessage(t *testing.T) { + messageID := "om_123" + chatID := "oc_123" + chatType := "group" + messageType := "text" + content := `{"text":"@_user_1 analyze this"}` + createdAt := "1710000000123" + openID := "ou_sender" + botOpenID := "ou_bot" + mentionKey := "@_user_1" + event := &larkim.P2MessageReceiveV1{Event: &larkim.P2MessageReceiveV1Data{ + Sender: &larkim.EventSender{SenderId: &larkim.UserId{OpenId: &openID}}, + Message: &larkim.EventMessage{ + MessageId: &messageID, ChatId: &chatID, ChatType: &chatType, + MessageType: &messageType, Content: &content, CreateTime: &createdAt, + Mentions: []*larkim.MentionEvent{{Key: &mentionKey, Id: &larkim.UserId{OpenId: &botOpenID}}}, + }, + }} + + message, ok := parseInbound(event, botOpenID, func() time.Time { return time.Time{} }) + if !ok { + t.Fatal("expected message to be parsed") + } + if message.ExternalID != messageID || message.ConversationID != chatID || message.ConversationType != "group" { + t.Fatalf("unexpected message identity: %+v", message) + } + if message.SenderCanonicalID != "feishu:"+openID || !message.Mentioned || message.Text != "analyze this" { + t.Fatalf("unexpected normalized message: %+v", message) + } + if want := time.UnixMilli(1710000000123).UTC(); !message.OccurredAt.Equal(want) { + t.Fatalf("occurred at = %s, want %s", message.OccurredAt, want) + } +} + +func TestParseInboundRejectsUnsupportedMessage(t *testing.T) { + messageType := "image" + event := &larkim.P2MessageReceiveV1{Event: &larkim.P2MessageReceiveV1Data{ + Sender: &larkim.EventSender{}, + Message: &larkim.EventMessage{MessageType: &messageType}, + }} + + if _, ok := parseInbound(event, "", time.Now); ok { + t.Fatal("expected image message to be ignored") + } +} diff --git a/pkg/channel/registry.go b/pkg/channel/registry.go deleted file mode 100644 index bf5f644..0000000 --- a/pkg/channel/registry.go +++ /dev/null @@ -1,81 +0,0 @@ -package channel - -import ( - "errors" - "fmt" - "sort" - "strings" -) - -type RegistryOption func(*Registry) error - -func WithFactory(factory Factory) RegistryOption { - return func(registry *Registry) error { return registry.Register(factory) } -} - -type Registry struct{ factories map[string]Factory } - -func NewRegistry(opts ...RegistryOption) (*Registry, error) { - registry := &Registry{factories: make(map[string]Factory)} - for _, option := range opts { - if option != nil { - if err := option(registry); err != nil { - return nil, err - } - } - } - return registry, nil -} -func (r *Registry) Register(factory Factory) error { - if factory == nil { - return errors.New("channel factory is required") - } - typeName := strings.ToLower(strings.TrimSpace(factory.Type())) - descriptor := factory.Descriptor() - if typeName == "" || descriptor.Type != typeName || strings.TrimSpace(descriptor.DisplayName) == "" { - return errors.New("channel factory descriptor is invalid") - } - if _, exists := r.factories[typeName]; exists { - return fmt.Errorf("register channel factory %q: duplicate type", typeName) - } - if err := validateDescriptor(descriptor); err != nil { - return fmt.Errorf("register channel factory %q: %w", typeName, err) - } - r.factories[typeName] = factory - return nil -} -func (r *Registry) Factory(channelType string) (Factory, bool) { - factory, ok := r.factories[strings.ToLower(strings.TrimSpace(channelType))] - return factory, ok -} -func (r *Registry) Descriptors() []Descriptor { - result := make([]Descriptor, 0, len(r.factories)) - for _, factory := range r.factories { - result = append(result, factory.Descriptor()) - } - sort.Slice(result, func(i, j int) bool { return result[i].Type < result[j].Type }) - return result -} -func validateDescriptor(descriptor Descriptor) error { - seenFields := make(map[string]struct{}) - for _, field := range descriptor.Fields { - if field.Name == "" { - return errors.New("config field name is required") - } - if _, exists := seenFields[field.Name]; exists { - return fmt.Errorf("duplicate config field %q", field.Name) - } - seenFields[field.Name] = struct{}{} - } - seenAttributes := make(map[string]struct{}) - for _, field := range descriptor.AttributeSchema.Fields { - if field.Key == "" || field.MaxBytes <= 0 { - return errors.New("attribute field key and positive max bytes are required") - } - if _, exists := seenAttributes[field.Key]; exists { - return fmt.Errorf("duplicate attribute field %q", field.Key) - } - seenAttributes[field.Key] = struct{}{} - } - return nil -} diff --git a/pkg/git/exec.go b/pkg/git/exec.go deleted file mode 100644 index 2c5b236..0000000 --- a/pkg/git/exec.go +++ /dev/null @@ -1,222 +0,0 @@ -package git - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" -) - -type ClientOption func(*ExecClient) - -func WithExecutable(path string) ClientOption { - return func(client *ExecClient) { - if strings.TrimSpace(path) != "" { - client.executable = path - } - } -} - -func WithDefaultTimeout(timeout time.Duration) ClientOption { - return func(client *ExecClient) { - if timeout > 0 { - client.defaultTimeout = timeout - } - } -} - -// WithHTTPSCAFile adds a CA bundle for HTTPS Git remotes. It is useful for -// private Git servers with an organization-managed CA; system trust remains -// the default when the option is not supplied. -func WithHTTPSCAFile(path string) ClientOption { - return func(client *ExecClient) { - if strings.TrimSpace(path) != "" { - client.httpsCAFile = path - } - } -} - -type ExecClient struct { - executable string - defaultTimeout time.Duration - httpsCAFile string -} - -func NewClient(opts ...ClientOption) *ExecClient { - client := &ExecClient{executable: "git", defaultTimeout: 10 * time.Minute} - for _, option := range opts { - if option != nil { - option(client) - } - } - return client -} - -func (c *ExecClient) Probe(ctx context.Context, request ProbeRequest) error { - if strings.TrimSpace(request.Remote) == "" { - return errors.New("git probe requires remote") - } - timeout := request.Timeout - if timeout <= 0 { - timeout = c.defaultTimeout - } - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - env, cleanup, err := credentialEnvironment(request.Credential) - if err != nil { - return err - } - defer cleanup() - if _, err := c.run(ctx, env, nil, - "-c", "protocol.file.allow=never", "-c", "core.hooksPath=/dev/null", - "ls-remote", "--exit-code", "--", request.Remote, "HEAD", - ); err != nil { - return fmt.Errorf("git remote probe failed: %w", err) - } - return nil -} - -func (c *ExecClient) Fetch(ctx context.Context, request FetchRequest) (FetchResult, error) { - if err := validateFetchRequest(request); err != nil { - return FetchResult{}, err - } - timeout := request.Timeout - if timeout <= 0 { - timeout = c.defaultTimeout - } - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - env, cleanup, err := credentialEnvironment(request.Credential) - if err != nil { - return FetchResult{}, err - } - defer cleanup() - - if _, err := c.run(ctx, nil, nil, "init", "--initial-branch=mooncode", "--", request.Directory); err != nil { - return FetchResult{}, fmt.Errorf("initialize Git checkout: %w", err) - } - if _, err := c.run(ctx, nil, nil, "-C", request.Directory, "remote", "add", "origin", request.Remote); err != nil { - return FetchResult{}, fmt.Errorf("configure Git remote: %w", err) - } - target := strings.TrimSpace(request.Ref) - if target == "" { - target = "HEAD" - } - fetchArgs := []string{ - "-c", "protocol.file.allow=never", - "-c", "core.hooksPath=/dev/null", - "-c", "submodule.recurse=false", - "-c", "filter.lfs.smudge=", - "-c", "filter.lfs.process=", - "-c", "filter.lfs.required=false", - "-C", request.Directory, "fetch", "--no-tags", "--depth", strconv.Itoa(request.Depth), "--", "origin", target, - } - if _, err := c.run(ctx, env, nil, fetchArgs...); err != nil { - return FetchResult{}, fmt.Errorf("git fetch failed: %w", err) - } - if _, err := c.run(ctx, nil, nil, - "-c", "core.hooksPath=/dev/null", "-C", request.Directory, - "checkout", "--detach", "--force", "FETCH_HEAD", - ); err != nil { - return FetchResult{}, fmt.Errorf("checkout fetched commit: %w", err) - } - output, err := c.run(ctx, env, nil, "-C", request.Directory, "rev-parse", "HEAD") - if err != nil { - return FetchResult{}, fmt.Errorf("resolve fetched commit: %w", err) - } - commit := strings.TrimSpace(string(output)) - if len(commit) != 40 && len(commit) != 64 { - return FetchResult{}, errors.New("git returned an invalid commit hash") - } - return FetchResult{CommitSHA: commit}, nil -} - -func (c *ExecClient) run(ctx context.Context, extraEnv []string, stdout *bytes.Buffer, args ...string) ([]byte, error) { - command := exec.CommandContext(ctx, c.executable, args...) - command.Env = []string{ - "PATH=" + os.Getenv("PATH"), - "LANG=C.UTF-8", - "GIT_TERMINAL_PROMPT=0", - "GIT_CONFIG_NOSYSTEM=1", - "GIT_CONFIG_GLOBAL=/dev/null", - "GIT_ALLOW_PROTOCOL=https", - "GIT_LFS_SKIP_SMUDGE=1", - } - for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"} { - if value := os.Getenv(name); value != "" { - command.Env = append(command.Env, name+"="+value) - } - } - if c.httpsCAFile != "" { - command.Env = append(command.Env, "GIT_SSL_CAINFO="+c.httpsCAFile) - } - command.Env = append(command.Env, extraEnv...) - var output bytes.Buffer - var stderr bytes.Buffer - if stdout == nil { - command.Stdout = &output - } else { - command.Stdout = stdout - } - command.Stderr = &stderr - if err := command.Run(); err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() - } - return nil, err - } - return output.Bytes(), nil -} - -func validateFetchRequest(request FetchRequest) error { - if strings.TrimSpace(request.Remote) == "" || strings.TrimSpace(request.Directory) == "" { - return errors.New("git fetch requires remote and directory") - } - if !filepath.IsAbs(request.Directory) { - return errors.New("git fetch directory must be absolute") - } - if request.Depth <= 0 || request.Depth > 1000 { - return errors.New("git fetch depth must be between 1 and 1000") - } - if _, err := os.Stat(request.Directory); err == nil { - return errors.New("git fetch directory already exists") - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("inspect git fetch directory: %w", err) - } - return nil -} - -func credentialEnvironment(credential Credential) ([]string, func(), error) { - if credential.Secret() == "" { - return nil, func() {}, nil - } - directory, err := os.MkdirTemp("", "mooncode-git-credential-") - if err != nil { - return nil, nil, fmt.Errorf("create Git credential helper directory: %w", err) - } - cleanup := func() { _ = os.RemoveAll(directory) } - path := filepath.Join(directory, "askpass") - script := "#!/bin/sh\ncase \"$1\" in\n *Username*) printf '%s\\n' \"$MOONCODE_GIT_USERNAME\" ;;\n *) printf '%s\\n' \"$MOONCODE_GIT_SECRET\" ;;\nesac\n" - if err := os.WriteFile(path, []byte(script), 0o700); err != nil { - cleanup() - return nil, nil, fmt.Errorf("write Git credential helper: %w", err) - } - username := credential.Username() - if username == "" { - username = "git" - } - return []string{ - "GIT_ASKPASS=" + path, - "MOONCODE_GIT_USERNAME=" + username, - "MOONCODE_GIT_SECRET=" + credential.Secret(), - }, cleanup, nil -} - -var _ Client = (*ExecClient)(nil) diff --git a/pkg/git/git.go b/pkg/git/git.go deleted file mode 100644 index a3ab946..0000000 --- a/pkg/git/git.go +++ /dev/null @@ -1,73 +0,0 @@ -// Package git defines the Git execution boundary. Provider-specific remote and -// credential policies belong to pkg/scm; process execution belongs to a Client. -package git - -import ( - "context" - "time" -) - -type Credential struct { - username string - secret string -} - -func NewCredential(username, secret string) Credential { - return Credential{username: username, secret: secret} -} - -func (credential Credential) Username() string { - return credential.username -} - -// Secret returns the credential secret to the execution adapter. Callers must -// never log or persist the returned value. -func (credential Credential) Secret() string { - return credential.secret -} - -func (credential Credential) String() string { - return "[REDACTED]" -} - -func (credential Credential) GoString() string { - return "git.Credential{[REDACTED]}" -} - -type FetchRequest struct { - Provider string - Remote string - Ref string - Directory string - Credential Credential - Depth int - Timeout time.Duration -} - -type FetchResult struct { - CommitSHA string -} - -type ProbeRequest struct { - Provider string - Remote string - Credential Credential - Timeout time.Duration -} - -type Client interface { - Probe(ctx context.Context, request ProbeRequest) error - Fetch(ctx context.Context, request FetchRequest) (FetchResult, error) -} - -type Wrapper func(Client) Client - -// Wrap applies wrappers in declaration order: the first wrapper is outermost. -func Wrap(client Client, wrappers ...Wrapper) Client { - for index := len(wrappers) - 1; index >= 0; index-- { - if wrappers[index] != nil { - client = wrappers[index](client) - } - } - return client -} diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go deleted file mode 100644 index c0c565f..0000000 --- a/pkg/git/git_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package git - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestCredentialFormattingIsRedacted(t *testing.T) { - credential := NewCredential("moon", "super-secret") - require.Equal(t, "moon", credential.Username()) - require.Equal(t, "super-secret", credential.Secret()) - require.NotContains(t, fmt.Sprintf("%v", credential), "super-secret") - require.NotContains(t, fmt.Sprintf("%#v", credential), "super-secret") -} - -func TestWrapUsesDeclarationOrder(t *testing.T) { - var calls []string - wrapper := func(name string) Wrapper { - return func(next Client) Client { return &recordingClient{name: name, next: next, calls: &calls} } - } - client := Wrap(&recordingClient{name: "client", calls: &calls}, wrapper("outer"), wrapper("inner")) - require.NoError(t, client.Probe(t.Context(), ProbeRequest{})) - require.Equal(t, []string{"outer:before", "inner:before", "client", "inner:after", "outer:after"}, calls) -} - -type recordingClient struct { - name string - next Client - calls *[]string -} - -func (client *recordingClient) Probe(ctx context.Context, request ProbeRequest) error { - if client.next == nil { - *client.calls = append(*client.calls, client.name) - return nil - } - *client.calls = append(*client.calls, client.name+":before") - err := client.next.Probe(ctx, request) - *client.calls = append(*client.calls, client.name+":after") - return err -} -func (*recordingClient) Fetch(context.Context, FetchRequest) (FetchResult, error) { - return FetchResult{}, nil -} diff --git a/pkg/git/integration_test.go b/pkg/git/integration_test.go deleted file mode 100644 index df4f92e..0000000 --- a/pkg/git/integration_test.go +++ /dev/null @@ -1,139 +0,0 @@ -//go:build integration - -package git - -import ( - "bytes" - "encoding/pem" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestHTTPSGitPublicAndPrivateRepositories(t *testing.T) { - root := t.TempDir() - createHTTPRepository(t, root, "public.git", "public checkout\n") - createHTTPRepository(t, root, "private.git", "private checkout\n") - - server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if strings.HasPrefix(request.URL.Path, "/private.git/") { - username, password, ok := request.BasicAuth() - if !ok || username != "moon" || password != "secret-token" { - writer.Header().Set("WWW-Authenticate", `Basic realm="MoonCode test"`) - writer.WriteHeader(http.StatusUnauthorized) - return - } - } - serveGitHTTP(root, writer, request) - })) - t.Cleanup(server.Close) - t.Setenv("NO_PROXY", "127.0.0.1,localhost") - - caFile := filepath.Join(t.TempDir(), "test-ca.pem") - require.NoError(t, os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600)) - client := NewClient(WithDefaultTimeout(10*time.Second), WithHTTPSCAFile(caFile)) - - t.Run("public", func(t *testing.T) { - assertGitRoundTrip(t, client, server.URL+"/public.git", Credential{}, "public checkout\n") - }) - t.Run("private rejects missing credential", func(t *testing.T) { - err := client.Probe(t.Context(), ProbeRequest{Remote: server.URL + "/private.git"}) - require.Error(t, err) - }) - t.Run("private", func(t *testing.T) { - assertGitRoundTrip(t, client, server.URL+"/private.git", NewCredential("moon", "secret-token"), "private checkout\n") - }) -} - -func serveGitHTTP(projectRoot string, writer http.ResponseWriter, request *http.Request) { - command := exec.Command("git", "http-backend") - command.Env = []string{ - "PATH=" + os.Getenv("PATH"), - "GIT_CONFIG_NOSYSTEM=1", - "GIT_CONFIG_GLOBAL=/dev/null", - "GIT_PROJECT_ROOT=" + projectRoot, - "GIT_HTTP_EXPORT_ALL=1", - "REQUEST_METHOD=" + request.Method, - "PATH_INFO=" + request.URL.Path, - "QUERY_STRING=" + request.URL.RawQuery, - "CONTENT_TYPE=" + request.Header.Get("Content-Type"), - "CONTENT_LENGTH=" + strconv.FormatInt(request.ContentLength, 10), - "SERVER_PROTOCOL=HTTP/1.1", - "REMOTE_ADDR=" + request.RemoteAddr, - } - command.Stdin = request.Body - output, err := command.Output() - if err != nil { - http.Error(writer, "Git fixture failed", http.StatusInternalServerError) - return - } - parts := bytes.SplitN(output, []byte("\r\n\r\n"), 2) - if len(parts) != 2 { - http.Error(writer, "Git fixture returned an invalid response", http.StatusInternalServerError) - return - } - status := http.StatusOK - for _, line := range strings.Split(string(parts[0]), "\r\n") { - name, value, found := strings.Cut(line, ":") - if !found { - continue - } - value = strings.TrimSpace(value) - if strings.EqualFold(name, "Status") { - if code, parseErr := strconv.Atoi(strings.Fields(value)[0]); parseErr == nil { - status = code - } - continue - } - writer.Header().Add(name, value) - } - writer.WriteHeader(status) - _, _ = writer.Write(parts[1]) -} - -func createHTTPRepository(t *testing.T, root, name, content string) { - t.Helper() - worktree := t.TempDir() - runGit(t, worktree, "init", "--initial-branch=main") - require.NoError(t, os.WriteFile(filepath.Join(worktree, "README.md"), []byte(content), 0o600)) - runGit(t, worktree, "add", "README.md") - runGit(t, worktree, "-c", "user.name=MoonCode", "-c", "user.email=mooncode@example.invalid", "commit", "-m", "fixture") - bare := filepath.Join(root, name) - runGit(t, root, "clone", "--bare", worktree, bare) - runGit(t, bare, "update-server-info") -} - -func assertGitRoundTrip(t *testing.T, client Client, remote string, credential Credential, expected string) { - t.Helper() - require.NoError(t, client.Probe(t.Context(), ProbeRequest{Remote: remote, Credential: credential})) - checkout := filepath.Join(t.TempDir(), "checkout") - fetched, err := client.Fetch(t.Context(), FetchRequest{Provider: "generic", Remote: remote, Directory: checkout, Credential: credential, Depth: 1}) - require.NoError(t, err) - require.NotEmpty(t, fetched.CommitSHA) - gitConfig, err := os.ReadFile(filepath.Join(checkout, ".git", "config")) - require.NoError(t, err) - if credential.Secret() != "" { - require.NotContains(t, string(gitConfig), credential.Secret()) - } - require.NotContains(t, string(gitConfig), "mooncode-git-credential") - contents, err := os.ReadFile(filepath.Join(checkout, "README.md")) - require.NoError(t, err) - require.Equal(t, expected, string(contents)) -} - -func runGit(t *testing.T, directory string, args ...string) { - t.Helper() - command := exec.Command("git", args...) - command.Dir = directory - command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") - output, err := command.CombinedOutput() - require.NoError(t, err, string(output)) -} diff --git a/pkg/gitrepo/authentication_test.go b/pkg/gitrepo/authentication_test.go new file mode 100644 index 0000000..8e69667 --- /dev/null +++ b/pkg/gitrepo/authentication_test.go @@ -0,0 +1,46 @@ +package gitrepo + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestGitAuthenticationFailureIsPermanent(t *testing.T) { + binary := filepath.Join(t.TempDir(), "git") + if err := os.WriteFile(binary, []byte("#!/bin/sh\necho 'fatal: Authentication failed for repository' >&2\nexit 1\n"), 0o700); err != nil { + t.Fatal(err) + } + manager, err := NewManager(t.TempDir(), WithGitBinary(binary)) + if err != nil { + t.Fatal(err) + } + + _, err = manager.run(context.Background(), nil, "fetch") + if !errors.Is(err, ErrAuthentication) || !IsPermanent(err) { + t.Fatalf("authentication error = %v, want permanent ErrAuthentication", err) + } +} + +func TestAuthenticationFailureMarkersAreSpecific(t *testing.T) { + for _, diagnostic := range []string{ + "fatal: Authentication failed", + "The requested URL returned error: 403", + "remote: HTTP 401", + } { + if !authenticationFailure(diagnostic) { + t.Fatalf("authentication diagnostic was not recognized: %q", diagnostic) + } + } + for _, diagnostic := range []string{ + "Could not resolve host", + "The requested URL returned error: 429", + "connection reset by peer", + } { + if authenticationFailure(diagnostic) { + t.Fatalf("transient diagnostic was classified as authentication: %q", diagnostic) + } + } +} diff --git a/pkg/gitrepo/capability.go b/pkg/gitrepo/capability.go new file mode 100644 index 0000000..435e13f --- /dev/null +++ b/pkg/gitrepo/capability.go @@ -0,0 +1,37 @@ +package gitrepo + +import ( + "context" + "fmt" + "strings" +) + +func (m *DefaultManager) validateGitConfigSupport(ctx context.Context, configs []GitConfig) error { + for _, config := range configs { + if config.Key != "http.curloptResolve" { + continue + } + + available, err := m.run(ctx, nil, "help", "--config") + if err != nil { + return fmt.Errorf("inspect Git HTTP capabilities: %w", err) + } + if !gitConfigAvailable(available, config.Key) { + return fmt.Errorf("%w: installed Git does not support %s", ErrRemoteBlocked, config.Key) + } + + return nil + } + + return nil +} + +func gitConfigAvailable(output, name string) bool { + for _, line := range strings.Split(output, "\n") { + if strings.TrimSpace(line) == name { + return true + } + } + + return false +} diff --git a/pkg/gitrepo/capture.go b/pkg/gitrepo/capture.go new file mode 100644 index 0000000..0ef9c13 --- /dev/null +++ b/pkg/gitrepo/capture.go @@ -0,0 +1,32 @@ +package gitrepo + +type outputCapture struct { + data []byte + limit int64 + exceeded bool +} + +func newOutputCapture(limit int64) *outputCapture { + return &outputCapture{data: make([]byte, 0, min(limit, 4096)), limit: limit} +} + +func (c *outputCapture) Write(value []byte) (int, error) { + written := len(value) + remaining := c.limit - int64(len(c.data)) + if remaining <= 0 { + c.exceeded = c.exceeded || written > 0 + + return written, nil + } + if int64(len(value)) > remaining { + value = value[:remaining] + c.exceeded = true + } + c.data = append(c.data, value...) + + return written, nil +} + +func (c *outputCapture) Exceeded() bool { return c.exceeded } + +func (c *outputCapture) String() string { return string(c.data) } diff --git a/pkg/gitrepo/diagnostic.go b/pkg/gitrepo/diagnostic.go new file mode 100644 index 0000000..1d5668d --- /dev/null +++ b/pkg/gitrepo/diagnostic.go @@ -0,0 +1,66 @@ +package gitrepo + +import ( + "encoding/base64" + "strings" +) + +func redactGitDiagnostic(output string, environment []string) string { + for _, entry := range environment { + key, value, ok := strings.Cut(entry, "=") + if !ok || !strings.HasPrefix(key, "GIT_CONFIG_VALUE_") || !strings.HasPrefix(strings.ToLower(value), "authorization: basic ") { + continue + } + + encoded := strings.TrimSpace(value[len("Authorization: Basic "):]) + output = strings.ReplaceAll(output, value, "[REDACTED]") + output = strings.ReplaceAll(output, encoded, "[REDACTED]") + + credential, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + continue + } + separator := strings.IndexByte(string(credential), ':') + if separator >= 0 { + output = strings.ReplaceAll(output, string(credential), "[REDACTED]") + if separator+1 < len(credential) { + output = strings.ReplaceAll(output, string(credential[separator+1:]), "[REDACTED]") + } + } + clear(credential) + } + + return output +} + +func safeGitError(output string) string { + output = strings.TrimSpace(output) + if output == "" { + return "no diagnostic output" + } + if len(output) > 1024 { + output = output[:1024] + } + + return output +} + +func authenticationFailure(diagnostic string) bool { + diagnostic = strings.ToLower(diagnostic) + for _, marker := range []string{ + "authentication failed", + "invalid username or password", + "returned error: 401", + "returned error: 403", + "http 401", + "http 403", + "access denied", + "could not read username", + } { + if strings.Contains(diagnostic, marker) { + return true + } + } + + return false +} diff --git a/pkg/gitrepo/environment.go b/pkg/gitrepo/environment.go new file mode 100644 index 0000000..59f267a --- /dev/null +++ b/pkg/gitrepo/environment.go @@ -0,0 +1,36 @@ +package gitrepo + +import "os" + +var proxyEnvironmentKeys = []string{ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +} + +func defaultEnvironment() []string { + environment := []string{ + "LANG=C", + "LC_ALL=C", + "PATH=/usr/local/bin:/usr/bin:/bin", + "TZ=UTC", + } + for _, key := range proxyEnvironmentKeys { + if value, ok := os.LookupEnv(key); ok { + environment = append(environment, key+"="+value) + } + } + + return environment +} + +func WithEnvironment(environment ...string) Option { + return func(manager *DefaultManager) { + manager.environment = append([]string(nil), environment...) + } +} diff --git a/pkg/gitrepo/integration_test.go b/pkg/gitrepo/integration_test.go new file mode 100644 index 0000000..5be8151 --- /dev/null +++ b/pkg/gitrepo/integration_test.go @@ -0,0 +1,108 @@ +package gitrepo + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestManagedBareRepositoryPinsImmutableSnapshots(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + source := filepath.Join(root, "source") + remote := filepath.Join(root, "remote.git") + mustGit(t, root, "init", "-b", "main", source) + mustGit(t, source, "config", "user.name", "MoonCode Test") + mustGit(t, source, "config", "user.email", "mooncode@example.com") + if err := os.WriteFile(filepath.Join(source, "content.txt"), []byte("first\n"), 0o600); err != nil { + t.Fatal(err) + } + mustGit(t, source, "add", "content.txt") + mustGit(t, source, "commit", "-m", "first") + firstSHA := strings.TrimSpace(mustGit(t, source, "rev-parse", "HEAD")) + mustGit(t, root, "clone", "--bare", source, remote) + + manager, err := NewManager(filepath.Join(root, "managed"), allowTestRemotes()) + if err != nil { + t.Fatal(err) + } + repositoryID, firstSnapshotID := uuid.New(), uuid.New() + first, err := manager.Provision(ctx, repositoryID, remote, "main", firstSnapshotID, Credential{}) + if err != nil { + t.Fatal(err) + } + if first.CommitSHA != firstSHA || first.MirrorSizeBytes <= 0 { + t.Fatalf("first snapshot = %s, want %s", first.CommitSHA, firstSHA) + } + + if err := os.WriteFile(filepath.Join(source, "content.txt"), []byte("second\n"), 0o600); err != nil { + t.Fatal(err) + } + mustGit(t, source, "commit", "-am", "second") + secondSHA := strings.TrimSpace(mustGit(t, source, "rev-parse", "HEAD")) + mustGit(t, source, "push", remote, "main") + secondSnapshotID := uuid.New() + second, err := manager.Sync(ctx, repositoryID, remote, "main", secondSnapshotID, Credential{}) + if err != nil { + t.Fatal(err) + } + if second.CommitSHA != secondSHA || second.CommitSHA == first.CommitSHA { + t.Fatal("expected refresh to resolve the new commit") + } + if _, err := manager.Sync(ctx, repositoryID, remote, "main", firstSnapshotID, Credential{}); err == nil { + t.Fatal("expected an existing snapshot ref to reject replacement") + } + + pinnedFirst := strings.TrimSpace(mustGit(t, root, "--git-dir", manager.Path(repositoryID), "rev-parse", "refs/mooncode/snapshots/"+firstSnapshotID.String())) + if pinnedFirst != firstSHA { + t.Fatalf("historical snapshot moved to %s", pinnedFirst) + } + refs := mustGit(t, root, "--git-dir", manager.Path(repositoryID), "for-each-ref", "--format=%(refname)") + if strings.Contains(refs, "refs/heads/") || !strings.Contains(refs, "refs/mooncode/remotes/origin/heads/main") { + t.Fatalf("unexpected managed refs:\n%s", refs) + } + if _, err := manager.resolve(ctx, manager.Path(repositoryID), "main~1"); !errors.Is(err, ErrRefInvalid) { + t.Fatalf("revision expression error = %v, want ErrRefInvalid", err) + } + if _, err := manager.resolve(ctx, manager.Path(repositoryID), "refs/heads/missing"); !errors.Is(err, ErrRevisionNotFound) { + t.Fatalf("missing full ref error = %v, want ErrRevisionNotFound", err) + } + + checkout, cleanup, err := manager.Checkout(ctx, repositoryID, firstSHA) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(filepath.Join(checkout, "content.txt")) + if err != nil { + t.Fatal(err) + } + if string(body) != "first\n" { + t.Fatalf("historical checkout contains %q", body) + } + if err := cleanup(); err != nil { + t.Fatal(err) + } +} + +func mustGit(t *testing.T, directory string, args ...string) string { + t.Helper() + command := exec.Command("git", args...) + command.Dir = directory + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } + return string(output) +} + +func allowTestRemotes() Option { + return WithRemoteValidator(RemoteValidatorFunc(func(context.Context, string) ([]GitConfig, error) { + return nil, nil + })) +} diff --git a/pkg/gitrepo/janitor.go b/pkg/gitrepo/janitor.go new file mode 100644 index 0000000..8a986eb --- /dev/null +++ b/pkg/gitrepo/janitor.go @@ -0,0 +1,92 @@ +package gitrepo + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +func (m *DefaultManager) CleanupStaleWorktrees(ctx context.Context, maxAge time.Duration) (int, error) { + if maxAge <= 0 { + return 0, errors.New("worktree maximum age must be greater than zero") + } + entries, err := os.ReadDir(m.root) + if err != nil { + return 0, fmt.Errorf("list repository root for stale worktrees: %w", err) + } + + cutoff := m.now().Add(-maxAge) + removed := 0 + for _, entry := range entries { + if ctx.Err() != nil { + return removed, ctx.Err() + } + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), ".analysis-") { + continue + } + info, err := entry.Info() + if err != nil { + return removed, fmt.Errorf("inspect stale worktree %s: %w", entry.Name(), err) + } + if !info.ModTime().Before(cutoff) { + continue + } + path := filepath.Join(m.root, entry.Name()) + if err := validateManagedChild(m.root, path); err != nil { + return removed, err + } + if err := os.RemoveAll(path); err != nil { + return removed, fmt.Errorf("remove stale worktree %s: %w", entry.Name(), err) + } + removed++ + } + if removed == 0 { + return 0, nil + } + + return removed, m.pruneWorktreeMetadata(ctx, entries) +} + +func (m *DefaultManager) pruneWorktreeMetadata(ctx context.Context, entries []os.DirEntry) error { + var result error + for _, entry := range entries { + if ctx.Err() != nil { + return errors.Join(result, ctx.Err()) + } + if !entry.IsDir() || !strings.HasSuffix(entry.Name(), ".git") { + continue + } + repositoryID, err := uuid.Parse(strings.TrimSuffix(entry.Name(), ".git")) + if err != nil { + continue + } + path := filepath.Join(m.root, entry.Name()) + if err := validateManagedChild(m.root, path); err != nil { + result = errors.Join(result, err) + continue + } + unlock := m.lock(repositoryID) + _, err = m.run(ctx, nil, "--git-dir", path, "worktree", "prune") + unlock() + if err != nil { + result = errors.Join(result, fmt.Errorf("prune worktree metadata for %s: %w", entry.Name(), err)) + } + } + + return result +} + +func validateManagedChild(root, path string) error { + relative, err := filepath.Rel(root, path) + if err != nil || relative == "." || filepath.IsAbs(relative) || strings.HasPrefix(relative, "..") || filepath.Dir(relative) != "." { + return fmt.Errorf("managed path %q is outside the repository root", path) + } + + return nil +} diff --git a/pkg/gitrepo/janitor_test.go b/pkg/gitrepo/janitor_test.go new file mode 100644 index 0000000..8145464 --- /dev/null +++ b/pkg/gitrepo/janitor_test.go @@ -0,0 +1,64 @@ +package gitrepo + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestCleanupStaleWorktreesOnlyRemovesManagedExpiredDirectories(t *testing.T) { + root := filepath.Join(t.TempDir(), "repositories") + now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + gitBinary := filepath.Join(t.TempDir(), "git") + if err := os.WriteFile(gitBinary, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + manager, err := NewManager(root, WithGitBinary(gitBinary), WithClock(func() time.Time { return now })) + if err != nil { + t.Fatal(err) + } + + stale := filepath.Join(root, ".analysis-stale") + fresh := filepath.Join(root, ".analysis-fresh") + unrelated := filepath.Join(root, "keep-me") + mirror := filepath.Join(root, uuid.NewString()+".git") + for _, path := range []string{stale, fresh, unrelated, mirror} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.Chtimes(stale, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(fresh, now.Add(-time.Hour), now.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + + removed, err := manager.CleanupStaleWorktrees(context.Background(), 24*time.Hour) + if err != nil || removed != 1 { + t.Fatalf("cleanup = (%d, %v), want (1, nil)", removed, err) + } + if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale worktree still exists: %v", err) + } + for _, path := range []string{fresh, unrelated, mirror} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("cleanup removed %s: %v", path, err) + } + } +} + +func TestCleanupStaleWorktreesRejectsInvalidMaximumAge(t *testing.T) { + manager, err := NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := manager.CleanupStaleWorktrees(context.Background(), 0); err == nil { + t.Fatal("zero worktree maximum age was accepted") + } +} diff --git a/pkg/gitrepo/lock_test.go b/pkg/gitrepo/lock_test.go new file mode 100644 index 0000000..a740d19 --- /dev/null +++ b/pkg/gitrepo/lock_test.go @@ -0,0 +1,37 @@ +package gitrepo + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestRepositoryLockSerializesSameRepository(t *testing.T) { + manager, err := NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + repositoryID := uuid.New() + unlock := manager.lock(repositoryID) + + acquired := make(chan struct{}) + go func() { + unlockSecond := manager.lock(repositoryID) + close(acquired) + unlockSecond() + }() + + select { + case <-acquired: + t.Fatal("same repository lock was acquired concurrently") + case <-time.After(25 * time.Millisecond): + } + unlock() + + select { + case <-acquired: + case <-time.After(time.Second): + t.Fatal("same repository lock was not released") + } +} diff --git a/pkg/gitrepo/manager.go b/pkg/gitrepo/manager.go new file mode 100644 index 0000000..6b38a73 --- /dev/null +++ b/pkg/gitrepo/manager.go @@ -0,0 +1,469 @@ +package gitrepo + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +const ( + headsRefspec = "+refs/heads/*:refs/mooncode/remotes/origin/heads/*" + tagsRefspec = "+refs/tags/*:refs/mooncode/remotes/origin/tags/*" + cleanupTimeout = 30 * time.Second +) + +var ( + ErrRefRequired = errors.New("ref is required") + ErrRefInvalid = errors.New("ref is not a valid branch or tag name") + ErrRevisionAmbiguous = errors.New("ref is ambiguous; use refs/heads/ or refs/tags/") + ErrRevisionNotFound = errors.New("ref was not found in the repository") + ErrInvalidCommitID = errors.New("git returned an invalid commit ID") + ErrMirrorQuota = errors.New("repository mirror exceeds configured storage limit") + ErrAuthentication = errors.New("repository authentication failed") + ErrCommandOutput = errors.New("git command output exceeded configured limit") +) + +func IsPermanent(err error) bool { + return errors.Is(err, ErrRefRequired) || errors.Is(err, ErrRefInvalid) || errors.Is(err, ErrRevisionAmbiguous) || errors.Is(err, ErrRevisionNotFound) || errors.Is(err, ErrInvalidCommitID) || errors.Is(err, ErrMirrorQuota) || errors.Is(err, ErrAuthentication) || errors.Is(err, ErrCommandOutput) || errors.Is(err, ErrRemoteBlocked) +} + +type Credential struct { + Username string + Token []byte +} + +type Snapshot struct { + CommitSHA string + Author string + AuthoredAt time.Time + Title string + MirrorSizeBytes int64 +} + +type Manager interface { + Provision(context.Context, uuid.UUID, string, string, uuid.UUID, Credential) (Snapshot, error) + Sync(context.Context, uuid.UUID, string, string, uuid.UUID, Credential) (Snapshot, error) + Checkout(context.Context, uuid.UUID, string) (string, func() error, error) + ReleaseSnapshot(context.Context, uuid.UUID, uuid.UUID, string) error + Purge(context.Context, uuid.UUID) error + Path(uuid.UUID) string +} + +type WorktreeJanitor interface { + CleanupStaleWorktrees(context.Context, time.Duration) (int, error) +} + +type Option func(*DefaultManager) + +type DefaultManager struct { + root string + gitBin string + now func() time.Time + maxMirrorBytes int64 + remoteValidator RemoteValidator + environment []string + maxOutputBytes int64 + repositoryLocks [256]sync.Mutex +} + +func WithGitBinary(path string) Option { + return func(manager *DefaultManager) { manager.gitBin = path } +} + +func WithClock(now func() time.Time) Option { + return func(manager *DefaultManager) { + manager.now = now + } +} + +func WithMaxMirrorBytes(maxBytes int64) Option { + return func(manager *DefaultManager) { manager.maxMirrorBytes = maxBytes } +} + +func WithRemoteValidator(validator RemoteValidator) Option { + return func(manager *DefaultManager) { manager.remoteValidator = validator } +} + +func WithMaxCommandOutputBytes(maxBytes int64) Option { + return func(manager *DefaultManager) { manager.maxOutputBytes = maxBytes } +} + +func NewManager(root string, options ...Option) (*DefaultManager, error) { + absolute, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve repository root: %w", err) + } + manager := &DefaultManager{ + root: absolute, gitBin: "git", now: time.Now, + remoteValidator: NewRemoteValidator(), environment: defaultEnvironment(), maxOutputBytes: 16 << 20, + } + for _, option := range options { + option(manager) + } + if manager.remoteValidator == nil { + return nil, errors.New("remote validator is required") + } + if manager.maxOutputBytes <= 0 { + return nil, errors.New("maximum Git command output must be greater than zero") + } + if err := os.MkdirAll(manager.root, 0o700); err != nil { + return nil, fmt.Errorf("create repository root: %w", err) + } + + return manager, nil +} + +func (m *DefaultManager) Path(id uuid.UUID) string { return filepath.Join(m.root, id.String()+".git") } + +func (m *DefaultManager) lock(id uuid.UUID) func() { + lock := &m.repositoryLocks[int(id[0])] + lock.Lock() + + return lock.Unlock +} + +func (m *DefaultManager) Provision(ctx context.Context, id uuid.UUID, remoteURL, configuredRef string, snapshotID uuid.UUID, credential Credential) (Snapshot, error) { + unlock := m.lock(id) + defer unlock() + + remoteConfig, err := m.remoteValidator.Validate(ctx, remoteURL) + if err != nil { + return Snapshot{}, err + } + if err := m.validateGitConfigSupport(ctx, remoteConfig); err != nil { + return Snapshot{}, err + } + repositoryPath := m.Path(id) + created := false + if _, err := os.Stat(repositoryPath); errors.Is(err, os.ErrNotExist) { + if _, err := m.run(ctx, nil, "init", "--bare", repositoryPath); err != nil { + return Snapshot{}, err + } + created = true + } + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "remote", "get-url", "origin"); err != nil { + if _, addErr := m.run(ctx, nil, "--git-dir", repositoryPath, "remote", "add", "origin", remoteURL); addErr != nil { + return Snapshot{}, addErr + } + } else if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "remote", "set-url", "origin", remoteURL); err != nil { + return Snapshot{}, err + } + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "config", "--replace-all", "remote.origin.fetch", headsRefspec); err != nil { + return Snapshot{}, fmt.Errorf("configure repository branch refspec: %w", err) + } + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "config", "--add", "remote.origin.fetch", tagsRefspec); err != nil { + return Snapshot{}, fmt.Errorf("configure repository tag refspec: %w", err) + } + + return m.sync(ctx, repositoryPath, configuredRef, snapshotID, credential, remoteConfig, created) +} + +func (m *DefaultManager) Sync(ctx context.Context, id uuid.UUID, remoteURL string, configuredRef string, snapshotID uuid.UUID, credential Credential) (Snapshot, error) { + unlock := m.lock(id) + defer unlock() + + remoteConfig, err := m.remoteValidator.Validate(ctx, remoteURL) + if err != nil { + return Snapshot{}, err + } + if err := m.validateGitConfigSupport(ctx, remoteConfig); err != nil { + return Snapshot{}, err + } + repositoryPath := m.Path(id) + if _, err := os.Stat(repositoryPath); err != nil { + return Snapshot{}, fmt.Errorf("managed repository is unavailable: %w", err) + } + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "remote", "set-url", "origin", remoteURL); err != nil { + return Snapshot{}, fmt.Errorf("set repository remote: %w", err) + } + + return m.sync(ctx, repositoryPath, configuredRef, snapshotID, credential, remoteConfig, false) +} + +func (m *DefaultManager) sync(ctx context.Context, repositoryPath, configuredRef string, snapshotID uuid.UUID, credential Credential, remoteConfig []GitConfig, removeOnQuota bool) (Snapshot, error) { + previousRefs, err := m.prepareQuota(ctx, repositoryPath, removeOnQuota) + if err != nil { + return Snapshot{}, err + } + env := gitEnvironment(credential, remoteConfig) + if _, err := m.run(ctx, env, "--git-dir", repositoryPath, "fetch", "--force", "--prune", "--no-tags", "origin", headsRefspec, tagsRefspec); err != nil { + if _, quotaErr := m.enforceQuota(ctx, repositoryPath, previousRefs, "", "", removeOnQuota); quotaErr != nil { + return Snapshot{}, quotaErr + } + + return Snapshot{}, fmt.Errorf("fetch repository: %w", err) + } + if _, err := m.enforceQuota(ctx, repositoryPath, previousRefs, "", "", removeOnQuota); err != nil { + return Snapshot{}, err + } + sha, err := m.resolve(ctx, repositoryPath, configuredRef) + if err != nil && commitPattern.MatchString(configuredRef) { + if _, fetchErr := m.run(ctx, env, "--git-dir", repositoryPath, "fetch", "--force", "--no-tags", "origin", configuredRef); fetchErr == nil { + if _, quotaErr := m.enforceQuota(ctx, repositoryPath, previousRefs, "", "", removeOnQuota); quotaErr != nil { + return Snapshot{}, quotaErr + } + sha, err = m.verify(ctx, repositoryPath, "FETCH_HEAD^{commit}") + } else if _, quotaErr := m.enforceQuota(ctx, repositoryPath, previousRefs, "", "", removeOnQuota); quotaErr != nil { + return Snapshot{}, quotaErr + } + } + if err != nil { + return Snapshot{}, err + } + snapshotRef := "refs/mooncode/snapshots/" + snapshotID.String() + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "update-ref", snapshotRef, sha, ""); err != nil { + return Snapshot{}, fmt.Errorf("pin snapshot: %w", err) + } + mirrorSize, err := m.enforceQuota(ctx, repositoryPath, previousRefs, snapshotRef, sha, removeOnQuota) + if err != nil { + return Snapshot{}, err + } + metadata, err := m.run(ctx, nil, "--git-dir", repositoryPath, "show", "-s", "--format=%an%x00%aI%x00%s", sha) + if err != nil { + cleanupContext, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + _, _ = m.run(cleanupContext, nil, "--git-dir", repositoryPath, "update-ref", "-d", snapshotRef, sha) + cancel() + return Snapshot{}, fmt.Errorf("read commit metadata: %w", err) + } + parts := strings.SplitN(strings.TrimSpace(metadata), "\x00", 3) + result := Snapshot{CommitSHA: sha} + if len(parts) > 0 { + result.Author = parts[0] + } + if len(parts) > 1 { + result.AuthoredAt, _ = time.Parse(time.RFC3339, parts[1]) + } + if len(parts) > 2 { + result.Title = parts[2] + } + result.MirrorSizeBytes = mirrorSize + + return result, nil +} + +func (m *DefaultManager) resolve(ctx context.Context, repositoryPath, configuredRef string) (string, error) { + value := strings.TrimSpace(configuredRef) + if value == "" { + return "", ErrRefRequired + } + if strings.HasPrefix(value, "refs/heads/") { + valid, err := m.validRef(ctx, value) + if err != nil { + return "", err + } + if !valid { + return "", ErrRefInvalid + } + + return m.verifyFetchedRef(ctx, repositoryPath, "refs/mooncode/remotes/origin/heads/"+strings.TrimPrefix(value, "refs/heads/")+"^{commit}") + } + if strings.HasPrefix(value, "refs/tags/") { + valid, err := m.validRef(ctx, value) + if err != nil { + return "", err + } + if !valid { + return "", ErrRefInvalid + } + + return m.verifyFetchedRef(ctx, repositoryPath, "refs/mooncode/remotes/origin/tags/"+strings.TrimPrefix(value, "refs/tags/")+"^{commit}") + } + if strings.HasPrefix(value, "refs/") { + return "", ErrRefInvalid + } + if !commitPattern.MatchString(value) { + valid, err := m.validRef(ctx, "refs/heads/"+value) + if err != nil { + return "", err + } + if !valid { + return "", ErrRefInvalid + } + } + branch, branchErr := m.verify(ctx, repositoryPath, "refs/mooncode/remotes/origin/heads/"+value+"^{commit}") + tag, tagErr := m.verify(ctx, repositoryPath, "refs/mooncode/remotes/origin/tags/"+value+"^{commit}") + if branchErr == nil && tagErr == nil && branch != tag { + return "", ErrRevisionAmbiguous + } + if branchErr == nil { + return branch, nil + } + if tagErr == nil { + return tag, nil + } + if commitPattern.MatchString(value) { + return m.verifyFetchedRef(ctx, repositoryPath, value+"^{commit}") + } + + return "", ErrRevisionNotFound +} + +func (m *DefaultManager) validRef(ctx context.Context, value string) (bool, error) { + _, err := m.run(ctx, nil, "check-ref-format", value) + if ctx.Err() != nil { + return false, ctx.Err() + } + + return err == nil, nil +} + +func (m *DefaultManager) verifyFetchedRef(ctx context.Context, repositoryPath, revision string) (string, error) { + sha, err := m.verify(ctx, repositoryPath, revision) + if err != nil { + return "", ErrRevisionNotFound + } + + return sha, nil +} + +func (m *DefaultManager) verify(ctx context.Context, repositoryPath, revision string) (string, error) { + output, err := m.run(ctx, nil, "--git-dir", repositoryPath, "rev-parse", "--verify", revision) + if err != nil { + return "", err + } + sha := strings.TrimSpace(output) + if !commitPattern.MatchString(sha) { + return "", ErrInvalidCommitID + } + + return sha, nil +} + +func (m *DefaultManager) Checkout(ctx context.Context, id uuid.UUID, commitSHA string) (string, func() error, error) { + unlock := m.lock(id) + defer unlock() + + repositoryPath := m.Path(id) + directory, err := os.MkdirTemp(m.root, ".analysis-") + if err != nil { + return "", nil, fmt.Errorf("create analysis worktree: %w", err) + } + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "worktree", "add", "--detach", directory, commitSHA); err != nil { + _ = os.RemoveAll(directory) + return "", nil, fmt.Errorf("create detached worktree: %w", err) + } + cleanup := func() error { + unlockCleanup := m.lock(id) + defer unlockCleanup() + + cleanupContext, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + + _, removeErr := m.run(cleanupContext, nil, "--git-dir", repositoryPath, "worktree", "remove", "--force", directory) + if removeErr != nil { + _ = os.RemoveAll(directory) + } + return removeErr + } + + return directory, cleanup, nil +} + +func (m *DefaultManager) Purge(ctx context.Context, id uuid.UUID) error { + unlock := m.lock(id) + defer unlock() + + path := m.Path(id) + if err := validateManagedChild(m.root, path); err != nil { + return err + } + if _, err := m.run(ctx, nil, "--git-dir", path, "worktree", "prune"); err != nil && !errors.Is(err, os.ErrNotExist) { + _ = err + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("purge managed repository: %w", err) + } + + return nil +} + +func (m *DefaultManager) run(ctx context.Context, extraEnv []string, args ...string) (string, error) { + return m.runInput(ctx, "", extraEnv, args...) +} + +func (m *DefaultManager) runInput(ctx context.Context, input string, extraEnv []string, args ...string) (string, error) { + command := exec.CommandContext(ctx, m.gitBin, args...) + command.Env = append([]string(nil), m.environment...) + command.Env = append(command.Env, extraEnv...) + command.Env = append(command.Env, + "GIT_TERMINAL_PROMPT=0", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + ) + if input != "" { + command.Stdin = strings.NewReader(input) + } + output := newOutputCapture(m.maxOutputBytes) + command.Stdout = output + command.Stderr = output + err := command.Run() + if ctx.Err() != nil { + return "", fmt.Errorf("git command: %w", ctx.Err()) + } + if output.Exceeded() { + return "", ErrCommandOutput + } + if err != nil { + diagnostic := safeGitError(redactGitDiagnostic(output.String(), extraEnv)) + if authenticationFailure(diagnostic) { + return "", fmt.Errorf("%w: %s", ErrAuthentication, diagnostic) + } + + return "", fmt.Errorf("git command failed: %s", diagnostic) + } + + return output.String(), nil +} + +func credentialEnvironment(credential Credential) []string { + return gitEnvironment(credential, nil) +} + +func gitEnvironment(credential Credential, configs []GitConfig) []string { + entries := append([]GitConfig(nil), configs...) + if len(credential.Token) == 0 { + return gitConfigEnvironment(entries) + } + username := credential.Username + if username == "" { + username = "oauth2" + } + raw := make([]byte, 0, len(username)+1+len(credential.Token)) + raw = append(raw, username...) + raw = append(raw, ':') + raw = append(raw, credential.Token...) + value := base64.StdEncoding.EncodeToString(raw) + clear(raw) + entries = append(entries, GitConfig{Key: "http.extraHeader", Value: "Authorization: Basic " + value}) + + return gitConfigEnvironment(entries) +} + +func gitConfigEnvironment(configs []GitConfig) []string { + if len(configs) == 0 { + return nil + } + environment := make([]string, 0, 1+2*len(configs)) + environment = append(environment, "GIT_CONFIG_COUNT="+strconv.Itoa(len(configs))) + for index, config := range configs { + environment = append(environment, + "GIT_CONFIG_KEY_"+strconv.Itoa(index)+"="+config.Key, + "GIT_CONFIG_VALUE_"+strconv.Itoa(index)+"="+config.Value, + ) + } + + return environment +} + +var commitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40,64}$`) diff --git a/pkg/gitrepo/manager_test.go b/pkg/gitrepo/manager_test.go new file mode 100644 index 0000000..dfa6521 --- /dev/null +++ b/pkg/gitrepo/manager_test.go @@ -0,0 +1,184 @@ +package gitrepo + +import ( + "context" + "encoding/base64" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestCredentialEnvironmentUsesEphemeralHeader(t *testing.T) { + token := []byte("github_pat_secret") + environment := credentialEnvironment(Credential{Username: "octocat", Token: token}) + if len(environment) != 3 || environment[0] != "GIT_CONFIG_COUNT=1" || environment[1] != "GIT_CONFIG_KEY_0=http.extraHeader" { + t.Fatalf("unexpected credential environment: %v", environment) + } + encoded := strings.TrimPrefix(environment[2], "GIT_CONFIG_VALUE_0=Authorization: Basic ") + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + defer clear(decoded) + if string(decoded) != "octocat:github_pat_secret" { + t.Fatal("credential header did not contain the requested identity") + } + if string(token) != "github_pat_secret" { + t.Fatal("credential environment changed the caller-owned token") + } +} + +func TestGitProcessDoesNotInheritWorkerSecrets(t *testing.T) { + t.Setenv("MOONCODE_SECRETS_KEY", "must-not-reach-git") + t.Setenv("MOONCODE_DATABASE_URL", "postgres://secret") + t.Setenv("MOONCODE_HATCHET_TOKEN", "hatchet-secret") + binary := filepath.Join(t.TempDir(), "git") + script := `#!/bin/sh +if env | grep -E '^(MOONCODE_SECRETS_KEY|MOONCODE_DATABASE_URL|MOONCODE_HATCHET_TOKEN)=' >/dev/null; then + echo 'worker secret reached git' >&2 + exit 1 +fi +printf 'safe' +` + if err := os.WriteFile(binary, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + manager, err := NewManager(t.TempDir(), WithGitBinary(binary)) + if err != nil { + t.Fatal(err) + } + + output, err := manager.run(context.Background(), nil, "--version") + if err != nil { + t.Fatal(err) + } + if output != "safe" { + t.Fatalf("git output = %q, want safe", output) + } +} + +func TestGitProcessKeepsConfiguredNetworkProxy(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://proxy.example:8080") + binary := filepath.Join(t.TempDir(), "git") + if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' \"$HTTPS_PROXY\"\n"), 0o700); err != nil { + t.Fatal(err) + } + manager, err := NewManager(t.TempDir(), WithGitBinary(binary)) + if err != nil { + t.Fatal(err) + } + + output, err := manager.run(context.Background(), nil, "--version") + if err != nil { + t.Fatal(err) + } + if output != "http://proxy.example:8080" { + t.Fatalf("git proxy = %q", output) + } +} + +func TestManagerRejectsGitWithoutPinnedResolutionSupport(t *testing.T) { + binary := filepath.Join(t.TempDir(), "git") + script := `#!/bin/sh +if [ "$1" = help ] && [ "$2" = --config ]; then + printf '%s\n' 'http.followRedirects' + exit 0 +fi +exit 99 +` + if err := os.WriteFile(binary, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + validator := RemoteValidatorFunc(func(context.Context, string) ([]GitConfig, error) { + return []GitConfig{{Key: "http.curloptResolve", Value: "github.com:443:1.1.1.1"}}, nil + }) + manager, err := NewManager(t.TempDir(), WithGitBinary(binary), WithRemoteValidator(validator)) + if err != nil { + t.Fatal(err) + } + + _, err = manager.Provision(context.Background(), uuid.New(), "https://github.com/example/repo.git", "main", uuid.New(), Credential{}) + if !errors.Is(err, ErrRemoteBlocked) { + t.Fatalf("Provision() error = %v, want blocked unsupported Git", err) + } +} + +func TestGitConfigAvailableRequiresExactName(t *testing.T) { + if !gitConfigAvailable("http.followRedirects\nhttp.curloptResolve\n", "http.curloptResolve") { + t.Fatal("expected Git config capability") + } + if gitConfigAvailable("http.curloptResolveExtra\n", "http.curloptResolve") { + t.Fatal("accepted a partial Git config name") + } +} + +func TestGitFailureRedactsCredentialEnvironmentAndDecodedToken(t *testing.T) { + binary := filepath.Join(t.TempDir(), "git") + script := `#!/bin/sh +printf '%s\n' "$GIT_CONFIG_VALUE_0" >&2 +printf '%s\n' 'octocat:github_pat_secret' >&2 +exit 1 +` + if err := os.WriteFile(binary, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + manager, err := NewManager(t.TempDir(), WithGitBinary(binary)) + if err != nil { + t.Fatal(err) + } + environment := credentialEnvironment(Credential{Username: "octocat", Token: []byte("github_pat_secret")}) + + _, err = manager.run(context.Background(), environment, "fetch") + if err == nil { + t.Fatal("expected Git failure") + } + message := err.Error() + if strings.Contains(message, "github_pat_secret") || strings.Contains(message, "octocat:") || strings.Contains(message, "Authorization: Basic") { + t.Fatalf("Git diagnostic leaked a credential: %q", message) + } + if !strings.Contains(message, "[REDACTED]") { + t.Fatalf("Git diagnostic did not show redaction: %q", message) + } +} + +func TestGitCommandOutputIsBounded(t *testing.T) { + binary := filepath.Join(t.TempDir(), "git") + if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%040d' 0\n"), 0o700); err != nil { + t.Fatal(err) + } + manager, err := NewManager(t.TempDir(), WithGitBinary(binary), WithMaxCommandOutputBytes(32)) + if err != nil { + t.Fatal(err) + } + + _, err = manager.run(context.Background(), nil, "--version") + if !errors.Is(err, ErrCommandOutput) || !IsPermanent(err) { + t.Fatalf("run() error = %v, want permanent bounded-output error", err) + } +} + +func TestGitEnvironmentCombinesRemotePolicyAndCredential(t *testing.T) { + environment := gitEnvironment( + Credential{Username: "octocat", Token: []byte("github_pat_secret")}, + []GitConfig{ + {Key: "http.followRedirects", Value: "false"}, + {Key: "http.curloptResolve", Value: "github.com:443:8.8.8.8"}, + }, + ) + if len(environment) != 7 || environment[0] != "GIT_CONFIG_COUNT=3" { + t.Fatalf("unexpected Git config environment: %v", environment) + } + if environment[1] != "GIT_CONFIG_KEY_0=http.followRedirects" || environment[2] != "GIT_CONFIG_VALUE_0=false" { + t.Fatalf("remote redirect policy was not preserved: %v", environment) + } + if environment[3] != "GIT_CONFIG_KEY_1=http.curloptResolve" || environment[4] != "GIT_CONFIG_VALUE_1=github.com:443:8.8.8.8" { + t.Fatalf("validated address was not pinned: %v", environment) + } + if environment[5] != "GIT_CONFIG_KEY_2=http.extraHeader" || !strings.HasPrefix(environment[6], "GIT_CONFIG_VALUE_2=Authorization: Basic ") { + t.Fatalf("credential header was not appended: %v", environment) + } +} diff --git a/pkg/gitrepo/quota.go b/pkg/gitrepo/quota.go new file mode 100644 index 0000000..3b968c1 --- /dev/null +++ b/pkg/gitrepo/quota.go @@ -0,0 +1,176 @@ +package gitrepo + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +var quotaTrackedRefPrefixes = []string{ + "refs/mooncode/remotes/origin/", + "refs/remotes/origin/", +} + +type MirrorQuotaError struct { + LimitBytes int64 + ActualBytes int64 +} + +func (e *MirrorQuotaError) Error() string { + return fmt.Sprintf("%s: limit=%d actual=%d", ErrMirrorQuota, e.LimitBytes, e.ActualBytes) +} + +func (e *MirrorQuotaError) Is(target error) bool { return target == ErrMirrorQuota } + +func (m *DefaultManager) prepareQuota(ctx context.Context, repositoryPath string, removeOnQuota bool) (map[string]string, error) { + if m.maxMirrorBytes <= 0 { + return nil, nil + } + + size, err := directorySize(repositoryPath) + if err != nil { + return nil, fmt.Errorf("measure repository mirror: %w", err) + } + if size > m.maxMirrorBytes { + quotaErr := &MirrorQuotaError{LimitBytes: m.maxMirrorBytes, ActualBytes: size} + if removeOnQuota { + if err := m.removeMirror(repositoryPath); err != nil { + return nil, errors.Join(quotaErr, err) + } + } + + return nil, quotaErr + } + + return m.quotaTrackedRefs(ctx, repositoryPath) +} + +func (m *DefaultManager) enforceQuota(ctx context.Context, repositoryPath string, previousRefs map[string]string, snapshotRef, snapshotSHA string, removeOnQuota bool) (int64, error) { + size, err := directorySize(repositoryPath) + if err != nil { + return 0, fmt.Errorf("measure repository mirror: %w", err) + } + if m.maxMirrorBytes <= 0 || size <= m.maxMirrorBytes { + return size, nil + } + + quotaErr := &MirrorQuotaError{LimitBytes: m.maxMirrorBytes, ActualBytes: size} + if removeOnQuota { + if err := m.removeMirror(repositoryPath); err != nil { + return 0, errors.Join(quotaErr, err) + } + + return 0, quotaErr + } + if err := m.rollbackQuota(ctx, repositoryPath, previousRefs, snapshotRef, snapshotSHA); err != nil { + return 0, errors.Join(quotaErr, fmt.Errorf("rollback over-quota mirror: %w", err)) + } + cleanedSize, err := directorySize(repositoryPath) + if err != nil { + return 0, errors.Join(quotaErr, fmt.Errorf("measure rolled back repository mirror: %w", err)) + } + if cleanedSize > m.maxMirrorBytes { + return 0, errors.Join(quotaErr, fmt.Errorf("rolled back repository mirror remains over quota: size=%d", cleanedSize)) + } + + return 0, quotaErr +} + +func (m *DefaultManager) rollbackQuota(ctx context.Context, repositoryPath string, previousRefs map[string]string, snapshotRef, snapshotSHA string) error { + currentRefs, err := m.quotaTrackedRefs(ctx, repositoryPath) + if err != nil { + return err + } + + commands := []string{"start"} + if snapshotRef != "" { + commands = append(commands, "delete "+snapshotRef+" "+snapshotSHA) + } + refNames := make([]string, 0, len(currentRefs)+len(previousRefs)) + for ref := range currentRefs { + refNames = append(refNames, ref) + } + for ref := range previousRefs { + if _, ok := currentRefs[ref]; !ok { + refNames = append(refNames, ref) + } + } + sort.Strings(refNames) + for _, ref := range refNames { + currentSHA, current := currentRefs[ref] + previousSHA, previous := previousRefs[ref] + switch { + case current && previous && currentSHA != previousSHA: + commands = append(commands, "update "+ref+" "+previousSHA+" "+currentSHA) + case current && !previous: + commands = append(commands, "delete "+ref+" "+currentSHA) + case !current && previous: + commands = append(commands, "create "+ref+" "+previousSHA) + } + } + commands = append(commands, "prepare", "commit", "") + if _, err := m.runInput(ctx, strings.Join(commands, "\n"), nil, "--git-dir", repositoryPath, "update-ref", "--stdin"); err != nil { + return err + } + if err := os.Remove(filepath.Join(repositoryPath, "FETCH_HEAD")); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove fetch head: %w", err) + } + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "reflog", "expire", "--expire=now", "--all"); err != nil { + return err + } + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "repack", "-Ad"); err != nil { + return err + } + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "prune", "--expire=now"); err != nil { + return err + } + + return nil +} + +func (m *DefaultManager) quotaTrackedRefs(ctx context.Context, repositoryPath string) (map[string]string, error) { + args := []string{"--git-dir", repositoryPath, "for-each-ref", "--format=%(refname)%09%(objectname)"} + args = append(args, quotaTrackedRefPrefixes...) + output, err := m.run(ctx, nil, args...) + if err != nil { + return nil, err + } + refs := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 || !trackedQuotaRef(parts[0]) || !commitPattern.MatchString(parts[1]) { + return nil, fmt.Errorf("git returned an invalid managed ref") + } + refs[parts[0]] = parts[1] + } + + return refs, nil +} + +func trackedQuotaRef(ref string) bool { + for _, prefix := range quotaTrackedRefPrefixes { + if strings.HasPrefix(ref, prefix) { + return true + } + } + + return false +} + +func (m *DefaultManager) removeMirror(repositoryPath string) error { + if err := validateManagedChild(m.root, repositoryPath); err != nil { + return err + } + if err := os.RemoveAll(repositoryPath); err != nil { + return fmt.Errorf("remove over-quota repository mirror: %w", err) + } + + return nil +} diff --git a/pkg/gitrepo/quota_test.go b/pkg/gitrepo/quota_test.go new file mode 100644 index 0000000..9254653 --- /dev/null +++ b/pkg/gitrepo/quota_test.go @@ -0,0 +1,102 @@ +package gitrepo + +import ( + "context" + "crypto/rand" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestProvisionRemovesNewMirrorWhenQuotaIsExceeded(t *testing.T) { + manager, err := NewManager(t.TempDir(), WithMaxMirrorBytes(1), allowTestRemotes()) + if err != nil { + t.Fatal(err) + } + repositoryID := uuid.New() + + _, err = manager.Provision(context.Background(), repositoryID, filepath.Join(t.TempDir(), "remote.git"), "main", uuid.New(), Credential{}) + if !errors.Is(err, ErrMirrorQuota) { + t.Fatalf("Provision() error = %v, want mirror quota error", err) + } + if _, statErr := os.Stat(manager.Path(repositoryID)); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("over-quota provision left a mirror behind: %v", statErr) + } +} + +func TestSyncRollsBackFetchedObjectsAndRefsWhenQuotaIsExceeded(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + source := filepath.Join(root, "source") + remote := filepath.Join(root, "remote.git") + managed := filepath.Join(root, "managed") + mustGit(t, root, "init", "-b", "main", source) + mustGit(t, source, "config", "user.name", "MoonCode Test") + mustGit(t, source, "config", "user.email", "mooncode@example.com") + if err := os.WriteFile(filepath.Join(source, "content.txt"), []byte("first\n"), 0o600); err != nil { + t.Fatal(err) + } + mustGit(t, source, "add", "content.txt") + mustGit(t, source, "commit", "-m", "first") + firstSHA := strings.TrimSpace(mustGit(t, source, "rev-parse", "HEAD")) + mustGit(t, root, "clone", "--bare", source, remote) + + repositoryID, firstSnapshotID := uuid.New(), uuid.New() + manager, err := NewManager(managed, allowTestRemotes()) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Provision(ctx, repositoryID, remote, "main", firstSnapshotID, Credential{}); err != nil { + t.Fatal(err) + } + initialSize, err := directorySize(manager.Path(repositoryID)) + if err != nil { + t.Fatal(err) + } + limit := initialSize + 256*1024 + + large := make([]byte, 2*1024*1024) + if _, err := rand.Read(large); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "large.bin"), large, 0o600); err != nil { + t.Fatal(err) + } + clear(large) + mustGit(t, source, "add", "large.bin") + mustGit(t, source, "commit", "-m", "large update") + mustGit(t, source, "push", remote, "main") + + limited, err := NewManager(managed, WithMaxMirrorBytes(limit), allowTestRemotes()) + if err != nil { + t.Fatal(err) + } + secondSnapshotID := uuid.New() + _, err = limited.Sync(ctx, repositoryID, remote, "main", secondSnapshotID, Credential{}) + if !errors.Is(err, ErrMirrorQuota) { + t.Fatalf("Sync() error = %v, want mirror quota error", err) + } + + remoteSHA := strings.TrimSpace(mustGit(t, root, "--git-dir", limited.Path(repositoryID), "rev-parse", "refs/mooncode/remotes/origin/heads/main")) + if remoteSHA != firstSHA { + t.Fatalf("over-quota sync moved managed branch to %s, want %s", remoteSHA, firstSHA) + } + pinnedSHA := strings.TrimSpace(mustGit(t, root, "--git-dir", limited.Path(repositoryID), "rev-parse", "refs/mooncode/snapshots/"+firstSnapshotID.String())) + if pinnedSHA != firstSHA { + t.Fatalf("over-quota sync changed pinned snapshot to %s, want %s", pinnedSHA, firstSHA) + } + if output := mustGit(t, root, "--git-dir", limited.Path(repositoryID), "for-each-ref", "--format=%(refname)", "refs/mooncode/snapshots/"+secondSnapshotID.String()); strings.TrimSpace(output) != "" { + t.Fatalf("over-quota sync retained a new snapshot ref: %s", output) + } + finalSize, err := directorySize(limited.Path(repositoryID)) + if err != nil { + t.Fatal(err) + } + if finalSize > limit { + t.Fatalf("rolled back mirror size = %d, limit = %d", finalSize, limit) + } +} diff --git a/pkg/gitrepo/remote.go b/pkg/gitrepo/remote.go new file mode 100644 index 0000000..1d6777b --- /dev/null +++ b/pkg/gitrepo/remote.go @@ -0,0 +1,80 @@ +package gitrepo + +import ( + "errors" + "net/url" + "path" + "strings" +) + +type Remote struct { + ProviderType string + URL string + Normalized string + Name string +} + +func NormalizePublicRemote(raw string) (Remote, error) { + parsed, cleanPath, err := parseRemote(raw) + if err != nil { + return Remote{}, err + } + + providerType := "" + switch strings.ToLower(parsed.Hostname()) { + case "github.com": + providerType = "github" + case "gitlab.com": + providerType = "gitlab" + default: + return Remote{}, errors.New("public repository host is not supported") + } + + return buildRemote(providerType, parsed, cleanPath), nil +} + +func NormalizeRemote(providerType, providerBaseURL, raw string) (Remote, error) { + parsed, cleanPath, err := parseRemote(raw) + if err != nil { + return Remote{}, err + } + base, err := url.Parse(strings.TrimRight(providerBaseURL, "/")) + if err != nil || base.Scheme != "https" || !strings.EqualFold(base.Hostname(), parsed.Hostname()) { + return Remote{}, errors.New("repository URL does not belong to the selected provider connection") + } + if parsed.Port() != base.Port() { + return Remote{}, errors.New("repository URL port does not match provider connection") + } + basePath := strings.TrimSuffix(path.Clean("/"+base.Path), "/") + if basePath != "" && basePath != "/" && cleanPath != basePath && !strings.HasPrefix(cleanPath, basePath+"/") { + return Remote{}, errors.New("repository URL is outside provider base path") + } + return buildRemote(providerType, parsed, cleanPath), nil +} + +func parseRemote(raw string) (*url.URL, string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, "", errors.New("repository URL must be a credential-free HTTPS URL") + } + cleanPath := strings.TrimSuffix(path.Clean("/"+parsed.EscapedPath()), ".git") + cleanPath, err = url.PathUnescape(cleanPath) + if err != nil || cleanPath == "/" || strings.Contains(cleanPath, "..") { + return nil, "", errors.New("repository URL path is invalid") + } + parts := strings.Split(strings.Trim(cleanPath, "/"), "/") + if len(parts) < 2 { + return nil, "", errors.New("repository URL must include owner and repository") + } + + return parsed, cleanPath, nil +} + +func buildRemote(providerType string, parsed *url.URL, cleanPath string) Remote { + parts := strings.Split(strings.Trim(cleanPath, "/"), "/") + name := parts[len(parts)-1] + canonical := parsed.Scheme + "://" + strings.ToLower(parsed.Host) + cleanPath + ".git" + normalized := strings.ToLower(parsed.Host + cleanPath) + + return Remote{ProviderType: providerType, URL: canonical, Normalized: normalized, Name: name} +} diff --git a/pkg/gitrepo/remote_access.go b/pkg/gitrepo/remote_access.go new file mode 100644 index 0000000..852e4c3 --- /dev/null +++ b/pkg/gitrepo/remote_access.go @@ -0,0 +1,144 @@ +package gitrepo + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +var ErrRemoteBlocked = errors.New("repository remote is blocked by network policy") + +type GitConfig struct { + Key string + Value string +} + +type RemoteValidator interface { + Validate(context.Context, string) ([]GitConfig, error) +} + +type RemoteValidatorFunc func(context.Context, string) ([]GitConfig, error) + +func (f RemoteValidatorFunc) Validate(ctx context.Context, remoteURL string) ([]GitConfig, error) { + return f(ctx, remoteURL) +} + +type DNSResolver interface { + LookupIPAddr(context.Context, string) ([]net.IPAddr, error) +} + +type DefaultRemoteValidator struct { + resolver DNSResolver +} + +type RemoteValidatorOption func(*DefaultRemoteValidator) + +func WithDNSResolver(resolver DNSResolver) RemoteValidatorOption { + return func(validator *DefaultRemoteValidator) { validator.resolver = resolver } +} + +func NewRemoteValidator(options ...RemoteValidatorOption) *DefaultRemoteValidator { + validator := &DefaultRemoteValidator{resolver: net.DefaultResolver} + for _, option := range options { + option(validator) + } + + return validator +} + +func (v *DefaultRemoteValidator) Validate(ctx context.Context, remoteURL string) ([]GitConfig, error) { + parsed, err := url.Parse(strings.TrimSpace(remoteURL)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("%w: remote must be a credential-free HTTPS URL", ErrRemoteBlocked) + } + host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") + if host == "" { + return nil, fmt.Errorf("%w: remote host is required", ErrRemoteBlocked) + } + port := parsed.Port() + if port == "" { + port = "443" + } + if _, err := strconv.ParseUint(port, 10, 16); err != nil { + return nil, fmt.Errorf("%w: remote port is invalid", ErrRemoteBlocked) + } + + addresses, err := v.resolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolve repository remote: %w", err) + } + if len(addresses) == 0 { + return nil, fmt.Errorf("resolve repository remote: no addresses returned") + } + configs := []GitConfig{{Key: "http.followRedirects", Value: "false"}} + seen := make(map[string]struct{}, len(addresses)) + for _, address := range addresses { + ip := address.IP + if !publicRemoteIP(ip) { + return nil, fmt.Errorf("%w: remote resolved to a non-public address", ErrRemoteBlocked) + } + value := ip.String() + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + if ip.To4() == nil { + value = "[" + value + "]" + } + configs = append(configs, GitConfig{Key: "http.curloptResolve", Value: host + ":" + port + ":" + value}) + } + + return configs, nil +} + +func publicRemoteIP(ip net.IP) bool { + if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() { + return false + } + for _, network := range blockedRemoteNetworks { + if network.Contains(ip) { + return false + } + } + + return true +} + +var blockedRemoteNetworks = parseRemoteNetworks([]string{ + "0.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "192.0.0.0/24", + "192.0.2.0/24", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "64:ff9b:1::/48", + "100::/64", + "2001:db8::/32", + "fc00::/7", + "fe80::/10", + "ff00::/8", +}) + +func parseRemoteNetworks(values []string) []*net.IPNet { + networks := make([]*net.IPNet, 0, len(values)) + for _, value := range values { + _, network, err := net.ParseCIDR(value) + if err != nil { + panic("invalid built-in remote network: " + value) + } + networks = append(networks, network) + } + + return networks +} diff --git a/pkg/gitrepo/remote_access_test.go b/pkg/gitrepo/remote_access_test.go new file mode 100644 index 0000000..8d6453c --- /dev/null +++ b/pkg/gitrepo/remote_access_test.go @@ -0,0 +1,86 @@ +package gitrepo + +import ( + "context" + "errors" + "net" + "testing" +) + +type staticResolver struct { + addresses []net.IPAddr + err error +} + +func (r staticResolver) LookupIPAddr(context.Context, string) ([]net.IPAddr, error) { + return r.addresses, r.err +} + +func TestRemoteValidatorPinsValidatedPublicAddresses(t *testing.T) { + validator := NewRemoteValidator(WithDNSResolver(staticResolver{addresses: []net.IPAddr{ + {IP: net.ParseIP("8.8.8.8")}, + {IP: net.ParseIP("2001:4860:4860::8888")}, + {IP: net.ParseIP("8.8.8.8")}, + }})) + + configs, err := validator.Validate(context.Background(), "https://GitHub.com/owner/repo.git") + if err != nil { + t.Fatal(err) + } + if len(configs) != 3 || configs[0] != (GitConfig{Key: "http.followRedirects", Value: "false"}) || configs[1].Key != "http.curloptResolve" || configs[1].Value != "github.com:443:8.8.8.8" || configs[2].Value != "github.com:443:[2001:4860:4860::8888]" { + t.Fatalf("unexpected pinned remote config: %#v", configs) + } +} + +func TestRemoteValidatorRejectsNonPublicAddresses(t *testing.T) { + addresses := []string{ + "0.0.0.0", + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "192.0.2.1", + "198.18.0.1", + "224.0.0.1", + "::1", + "2001:db8::1", + "fc00::1", + "fe80::1", + } + for _, value := range addresses { + t.Run(value, func(t *testing.T) { + validator := NewRemoteValidator(WithDNSResolver(staticResolver{addresses: []net.IPAddr{{IP: net.ParseIP(value)}}})) + + _, err := validator.Validate(context.Background(), "https://github.com/owner/repo.git") + if !errors.Is(err, ErrRemoteBlocked) { + t.Fatalf("Validate() error = %v, want blocked remote", err) + } + }) + } +} + +func TestRemoteValidatorRejectsUnsafeURLsBeforeDNS(t *testing.T) { + validator := NewRemoteValidator(WithDNSResolver(staticResolver{addresses: []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}})) + values := []string{ + "http://github.com/owner/repo.git", + "file:///tmp/repo.git", + "https://token@github.com/owner/repo.git", + "https://github.com/owner/repo.git?token=secret", + "https://github.com/owner/repo.git#fragment", + } + for _, value := range values { + if _, err := validator.Validate(context.Background(), value); !errors.Is(err, ErrRemoteBlocked) { + t.Fatalf("Validate(%q) error = %v, want blocked remote", value, err) + } + } +} + +func TestRemoteValidatorPreservesResolutionFailuresAsRetryable(t *testing.T) { + want := errors.New("temporary DNS failure") + validator := NewRemoteValidator(WithDNSResolver(staticResolver{err: want})) + + _, err := validator.Validate(context.Background(), "https://github.com/owner/repo.git") + if !errors.Is(err, want) || errors.Is(err, ErrRemoteBlocked) { + t.Fatalf("Validate() error = %v, want retryable resolution failure", err) + } +} diff --git a/pkg/gitrepo/remote_test.go b/pkg/gitrepo/remote_test.go new file mode 100644 index 0000000..1b0cd55 --- /dev/null +++ b/pkg/gitrepo/remote_test.go @@ -0,0 +1,49 @@ +package gitrepo + +import "testing" + +func TestNormalizeRemote(t *testing.T) { + remote, err := NormalizeRemote("github", "https://github.com", "https://github.com/Owner/Repo.git") + if err != nil { + t.Fatal(err) + } + if remote.Name != "Repo" || remote.Normalized != "github.com/owner/repo" { + t.Fatalf("unexpected remote: %#v", remote) + } +} + +func TestNormalizeRemoteRejectsCredentialAndWrongHost(t *testing.T) { + for _, value := range []string{"https://token@github.com/o/r", "https://gitlab.com/o/r"} { + if _, err := NormalizeRemote("github", "https://github.com", value); err == nil { + t.Fatalf("expected %q to fail", value) + } + } +} + +func TestNormalizePublicRemoteInfersProvider(t *testing.T) { + tests := []struct { + url string + provider string + }{ + {url: "https://github.com/Owner/Repo", provider: "github"}, + {url: "https://gitlab.com/Group/Repo.git", provider: "gitlab"}, + } + + for _, test := range tests { + remote, err := NormalizePublicRemote(test.url) + if err != nil { + t.Fatal(err) + } + if remote.ProviderType != test.provider || remote.Name != "Repo" { + t.Fatalf("NormalizePublicRemote(%q) = %#v", test.url, remote) + } + } +} + +func TestNormalizePublicRemoteRejectsUnsupportedHostAndCredential(t *testing.T) { + for _, value := range []string{"https://code.example.com/o/r", "https://token@github.com/o/r"} { + if _, err := NormalizePublicRemote(value); err == nil { + t.Fatalf("expected %q to fail", value) + } + } +} diff --git a/pkg/gitrepo/size.go b/pkg/gitrepo/size.go new file mode 100644 index 0000000..b34cc0f --- /dev/null +++ b/pkg/gitrepo/size.go @@ -0,0 +1,26 @@ +package gitrepo + +import ( + "io/fs" + "path/filepath" +) + +func directorySize(root string) (int64, error) { + var size int64 + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type().IsRegular() { + info, err := entry.Info() + if err != nil { + return err + } + size += info.Size() + } + + return nil + }) + + return size, err +} diff --git a/pkg/gitrepo/snapshot.go b/pkg/gitrepo/snapshot.go new file mode 100644 index 0000000..1c6f17b --- /dev/null +++ b/pkg/gitrepo/snapshot.go @@ -0,0 +1,44 @@ +package gitrepo + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/google/uuid" +) + +func (m *DefaultManager) ReleaseSnapshot(ctx context.Context, repositoryID, snapshotID uuid.UUID, commitSHA string) error { + unlock := m.lock(repositoryID) + defer unlock() + + if !commitPattern.MatchString(commitSHA) { + return ErrInvalidCommitID + } + + repositoryPath := m.Path(repositoryID) + if _, err := os.Stat(repositoryPath); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return fmt.Errorf("inspect managed repository: %w", err) + } + snapshotRef := "refs/mooncode/snapshots/" + snapshotID.String() + current, err := m.run(ctx, nil, "--git-dir", repositoryPath, "for-each-ref", "--format=%(objectname)", snapshotRef) + if err != nil { + return fmt.Errorf("inspect snapshot pin: %w", err) + } + current = strings.TrimSpace(current) + if current == "" { + return nil + } + if !strings.EqualFold(current, commitSHA) { + return fmt.Errorf("release snapshot pin: expected commit %s but found %s", commitSHA, current) + } + if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "update-ref", "-d", snapshotRef, current); err != nil { + return fmt.Errorf("release snapshot pin: %w", err) + } + + return nil +} diff --git a/pkg/gitrepo/snapshot_test.go b/pkg/gitrepo/snapshot_test.go new file mode 100644 index 0000000..5999fe4 --- /dev/null +++ b/pkg/gitrepo/snapshot_test.go @@ -0,0 +1,88 @@ +package gitrepo + +import ( + "context" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestReleaseSnapshotRemovesOnlyExpectedPinAndIsIdempotent(t *testing.T) { + manager, err := NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + repositoryID, snapshotID := uuid.New(), uuid.New() + if _, err := manager.run(context.Background(), nil, "init", "--bare", manager.Path(repositoryID)); err != nil { + t.Fatal(err) + } + commitSHA, err := manager.runInput(context.Background(), "snapshot content", nil, "--git-dir", manager.Path(repositoryID), "hash-object", "-w", "--stdin") + if err != nil { + t.Fatal(err) + } + commitSHA = strings.TrimSpace(commitSHA) + ref := "refs/mooncode/snapshots/" + snapshotID.String() + if _, err := manager.run(context.Background(), nil, "--git-dir", manager.Path(repositoryID), "update-ref", ref, commitSHA); err != nil { + t.Fatal(err) + } + + if err := manager.ReleaseSnapshot(context.Background(), repositoryID, snapshotID, commitSHA); err != nil { + t.Fatal(err) + } + if err := manager.ReleaseSnapshot(context.Background(), repositoryID, snapshotID, commitSHA); err != nil { + t.Fatalf("second release must be idempotent: %v", err) + } + remaining, err := manager.run(context.Background(), nil, "--git-dir", manager.Path(repositoryID), "for-each-ref", "--format=%(refname)", ref) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(remaining) != "" { + t.Fatalf("snapshot ref still exists: %q", remaining) + } +} + +func TestReleaseSnapshotRejectsUnexpectedCommit(t *testing.T) { + manager, err := NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + repositoryID, snapshotID := uuid.New(), uuid.New() + if _, err := manager.run(context.Background(), nil, "init", "--bare", manager.Path(repositoryID)); err != nil { + t.Fatal(err) + } + commitSHA, err := manager.runInput(context.Background(), "snapshot content", nil, "--git-dir", manager.Path(repositoryID), "hash-object", "-w", "--stdin") + if err != nil { + t.Fatal(err) + } + commitSHA = strings.TrimSpace(commitSHA) + ref := "refs/mooncode/snapshots/" + snapshotID.String() + if _, err := manager.run(context.Background(), nil, "--git-dir", manager.Path(repositoryID), "update-ref", ref, commitSHA); err != nil { + t.Fatal(err) + } + + err = manager.ReleaseSnapshot(context.Background(), repositoryID, snapshotID, strings.Repeat("f", 40)) + if err == nil { + t.Fatal("unexpected commit was accepted") + } + remaining, lookupErr := manager.run(context.Background(), nil, "--git-dir", manager.Path(repositoryID), "for-each-ref", "--format=%(objectname)", ref) + if lookupErr != nil { + t.Fatal(lookupErr) + } + if strings.TrimSpace(remaining) != commitSHA { + t.Fatal("snapshot ref changed after rejected release") + } +} + +func TestReleaseSnapshotTreatsMissingMirrorAsAlreadyReleased(t *testing.T) { + manager, err := NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + if err := manager.ReleaseSnapshot( + context.Background(), uuid.New(), uuid.New(), strings.Repeat("a", 40), + ); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/scm/scm.go b/pkg/scm/scm.go deleted file mode 100644 index 1241ba1..0000000 --- a/pkg/scm/scm.go +++ /dev/null @@ -1,210 +0,0 @@ -// Package scm defines source-control provider registration and remote URL policy. -package scm - -import ( - "context" - "errors" - "fmt" - "net" - "net/url" - "sort" - "strings" -) - -var ( - ErrInvalidRemote = errors.New("scm: invalid remote") - ErrUnsafeAddress = errors.New("scm: unsafe network address") - ErrUnknownProvider = errors.New("scm: unknown provider") -) - -type Provider interface { - Type() string - Validate(ctx context.Context, remote string) (Remote, error) -} - -type Remote struct { - Provider string - URL string - Host string - Path string -} - -type Registry struct { - providers map[string]Provider -} - -type RegistryOption func(*Registry) error - -func WithProvider(provider Provider) RegistryOption { - return func(registry *Registry) error { return registry.Register(provider) } -} - -func NewRegistry(opts ...RegistryOption) (*Registry, error) { - registry := &Registry{providers: make(map[string]Provider)} - for _, option := range opts { - if option != nil { - if err := option(registry); err != nil { - return nil, err - } - } - } - return registry, nil -} - -func (r *Registry) Register(provider Provider) error { - if provider == nil || strings.TrimSpace(provider.Type()) == "" { - return errors.New("scm provider type is required") - } - providerType := strings.ToLower(provider.Type()) - if _, exists := r.providers[providerType]; exists { - return fmt.Errorf("register SCM provider %q: duplicate type", providerType) - } - r.providers[providerType] = provider - return nil -} - -func (r *Registry) Provider(providerType string) (Provider, error) { - provider, ok := r.providers[strings.ToLower(strings.TrimSpace(providerType))] - if !ok { - return nil, fmt.Errorf("%w: %s", ErrUnknownProvider, providerType) - } - return provider, nil -} - -func (r *Registry) Types() []string { - result := make([]string, 0, len(r.providers)) - for providerType := range r.providers { - result = append(result, providerType) - } - sort.Strings(result) - return result -} - -type Resolver interface { - LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) -} - -type PolicyOption func(*URLPolicy) - -func WithResolver(resolver Resolver) PolicyOption { - return func(policy *URLPolicy) { - if resolver != nil { - policy.resolver = resolver - } - } -} - -func WithAllowedPort(port string) PolicyOption { - return func(policy *URLPolicy) { - if port != "" { - policy.allowedPorts[port] = struct{}{} - } - } -} - -func WithAllowedNetwork(network *net.IPNet) PolicyOption { - return func(policy *URLPolicy) { - if network != nil { - policy.allowedNetworks = append(policy.allowedNetworks, network) - } - } -} - -type URLPolicy struct { - resolver Resolver - allowedPorts map[string]struct{} - allowedNetworks []*net.IPNet -} - -func NewURLPolicy(opts ...PolicyOption) *URLPolicy { - policy := &URLPolicy{resolver: net.DefaultResolver, allowedPorts: map[string]struct{}{"443": {}}} - for _, option := range opts { - if option != nil { - option(policy) - } - } - return policy -} - -func (p *URLPolicy) Validate(ctx context.Context, raw string) (*url.URL, error) { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil { - return nil, ErrInvalidRemote - } - if parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || parsed.Path == "/" { - return nil, ErrInvalidRemote - } - port := parsed.Port() - if port == "" { - port = "443" - } - if _, ok := p.allowedPorts[port]; !ok { - return nil, ErrInvalidRemote - } - addresses, err := p.resolver.LookupIPAddr(ctx, parsed.Hostname()) - if err != nil { - return nil, fmt.Errorf("resolve SCM remote host: %w", err) - } - if len(addresses) == 0 { - return nil, errors.New("resolve SCM remote host: no addresses returned") - } - for _, address := range addresses { - if unsafeIP(address.IP) && !p.networkAllowed(address.IP) { - return nil, fmt.Errorf("%w: host resolves to a private or special-use address", ErrUnsafeAddress) - } - } - parsed.Scheme = "https" - parsed.Host = strings.ToLower(parsed.Host) - parsed.Path = strings.TrimSuffix(parsed.Path, "/") - parsed.RawPath = "" - return parsed, nil -} - -func (p *URLPolicy) networkAllowed(ip net.IP) bool { - for _, network := range p.allowedNetworks { - if network.Contains(ip) { - return true - } - } - return false -} - -func unsafeIP(ip net.IP) bool { - return ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || - ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() -} - -type HTTPSProvider struct { - providerType string - policy *URLPolicy - hostSuffixes []string -} - -func NewHTTPSProvider(providerType string, policy *URLPolicy, hostSuffixes ...string) *HTTPSProvider { - if policy == nil { - policy = NewURLPolicy() - } - return &HTTPSProvider{providerType: strings.ToLower(providerType), policy: policy, hostSuffixes: hostSuffixes} -} - -func (p *HTTPSProvider) Type() string { return p.providerType } - -func (p *HTTPSProvider) Validate(ctx context.Context, raw string) (Remote, error) { - parsed, err := p.policy.Validate(ctx, raw) - if err != nil { - return Remote{}, err - } - if len(p.hostSuffixes) > 0 { - allowed := false - for _, suffix := range p.hostSuffixes { - if parsed.Hostname() == suffix || strings.HasSuffix(parsed.Hostname(), "."+suffix) { - allowed = true - break - } - } - if !allowed { - return Remote{}, ErrInvalidRemote - } - } - return Remote{Provider: p.providerType, URL: parsed.String(), Host: parsed.Hostname(), Path: parsed.Path}, nil -} diff --git a/pkg/scm/scm_test.go b/pkg/scm/scm_test.go deleted file mode 100644 index 767374d..0000000 --- a/pkg/scm/scm_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package scm - -import ( - "context" - "net" - "testing" - - "github.com/stretchr/testify/require" -) - -type fixedResolver []net.IPAddr - -func (r fixedResolver) LookupIPAddr(context.Context, string) ([]net.IPAddr, error) { return r, nil } - -func TestURLPolicyAllowsPublicHTTPS(t *testing.T) { - policy := NewURLPolicy(WithResolver(fixedResolver{{IP: net.ParseIP("8.8.8.8")}})) - remote, err := policy.Validate(context.Background(), "https://GitHub.com/moon/code.git") - require.NoError(t, err) - require.Equal(t, "github.com", remote.Hostname()) -} - -func TestURLPolicyRejectsPrivateAddressAndCredentials(t *testing.T) { - privatePolicy := NewURLPolicy(WithResolver(fixedResolver{{IP: net.ParseIP("127.0.0.1")}})) - _, err := privatePolicy.Validate(context.Background(), "https://git.example.com/moon/code.git") - require.ErrorIs(t, err, ErrUnsafeAddress) - - publicPolicy := NewURLPolicy(WithResolver(fixedResolver{{IP: net.ParseIP("8.8.8.8")}})) - _, err = publicPolicy.Validate(context.Background(), "https://token@git.example.com/moon/code.git") - require.ErrorIs(t, err, ErrInvalidRemote) -} - -func TestURLPolicyAllowsExplicitPrivateNetwork(t *testing.T) { - _, network, err := net.ParseCIDR("10.20.0.0/16") - require.NoError(t, err) - policy := NewURLPolicy( - WithResolver(fixedResolver{{IP: net.ParseIP("10.20.1.5")}}), - WithAllowedNetwork(network), - ) - remote, err := policy.Validate(context.Background(), "https://git.internal.example/team/code.git") - require.NoError(t, err) - require.Equal(t, "git.internal.example", remote.Hostname()) -} - -func TestRegistryRejectsDuplicateProvider(t *testing.T) { - provider := NewHTTPSProvider("github", NewURLPolicy()) - _, err := NewRegistry(WithProvider(provider), WithProvider(provider)) - require.ErrorContains(t, err, "duplicate") -} diff --git a/pkg/storage/capabilities.go b/pkg/storage/capabilities.go deleted file mode 100644 index 7bda79d..0000000 --- a/pkg/storage/capabilities.go +++ /dev/null @@ -1,26 +0,0 @@ -package storage - -import ( - "context" - "time" -) - -type URLSigner interface { - PresignGet(ctx context.Context, location Location, ttl time.Duration) (string, error) -} - -type ListOptions struct { - Cursor string - Limit int -} - -type ListOption func(*ListOptions) - -type Page struct { - Items []Metadata - NextCursor string -} - -type Lister interface { - List(ctx context.Context, namespace, prefix string, opts ...ListOption) (Page, error) -} diff --git a/pkg/storage/s3/integration_test.go b/pkg/storage/s3/integration_test.go deleted file mode 100644 index 598d8da..0000000 --- a/pkg/storage/s3/integration_test.go +++ /dev/null @@ -1,55 +0,0 @@ -//go:build integration - -package s3 - -import ( - "context" - "fmt" - "net/http" - "sync/atomic" - "testing" - "time" - - "github.com/mooncode-ai/mooncode/pkg/storage/storagetest" - "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -func TestMinIOStorageContract(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) - defer cancel() - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "minio/minio:RELEASE.2025-04-22T22-12-26Z", - Env: map[string]string{"MINIO_ROOT_USER": "mooncode", "MINIO_ROOT_PASSWORD": "mooncode-development-secret"}, - Cmd: []string{"server", "/data", "--console-address", ":9001"}, - ExposedPorts: []string{"9000/tcp"}, - WaitingFor: wait.ForHTTP("/minio/health/ready").WithPort("9000/tcp").WithStartupTimeout(90 * time.Second), - }, - Started: true, - }) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) - host, err := container.Host(ctx) - require.NoError(t, err) - port, err := container.MappedPort(ctx, "9000/tcp") - require.NoError(t, err) - - transport := &countingTransport{next: http.DefaultTransport.(*http.Transport).Clone()} - store, err := New(fmt.Sprintf("%s:%s", host, port.Port()), "mooncode", "mooncode-development-secret", WithRegion("us-east-1"), WithTransport(transport)) - require.NoError(t, err) - require.NoError(t, store.EnsureBucket(t.Context(), "mooncode-contract")) - storagetest.Run(t, store, "mooncode-contract") - require.Positive(t, transport.requests.Load()) -} - -type countingTransport struct { - next http.RoundTripper - requests atomic.Int64 -} - -func (transport *countingTransport) RoundTrip(request *http.Request) (*http.Response, error) { - transport.requests.Add(1) - return transport.next.RoundTrip(request) -} diff --git a/pkg/storage/s3/s3.go b/pkg/storage/s3/s3.go deleted file mode 100644 index efdeaaa..0000000 --- a/pkg/storage/s3/s3.go +++ /dev/null @@ -1,212 +0,0 @@ -// Package s3 implements storage.Store for S3-compatible services such as MinIO. -package s3 - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "strings" - - "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" - storagecore "github.com/mooncode-ai/mooncode/pkg/storage" -) - -type Option func(*options) - -type options struct { - secure bool - region string - sessionToken string - transport http.RoundTripper -} - -func WithSecure(secure bool) Option { - return func(cfg *options) { cfg.secure = secure } -} - -func WithRegion(region string) Option { - return func(cfg *options) { cfg.region = region } -} - -func WithSessionToken(token string) Option { - return func(cfg *options) { cfg.sessionToken = token } -} - -func WithTransport(transport http.RoundTripper) Option { - return func(cfg *options) { - if transport != nil { - cfg.transport = transport - } - } -} - -type Store struct { - client *minio.Client -} - -func New(endpoint, accessKey, secretKey string, opts ...Option) (*Store, error) { - if strings.TrimSpace(endpoint) == "" { - return nil, errors.New("s3 endpoint is required") - } - cfg := options{} - for _, option := range opts { - if option != nil { - option(&cfg) - } - } - client, err := minio.New(endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(accessKey, secretKey, cfg.sessionToken), - Secure: cfg.secure, Region: cfg.region, Transport: cfg.transport, - }) - if err != nil { - return nil, fmt.Errorf("create S3 client: %w", err) - } - return &Store{client: client}, nil -} - -func (s *Store) EnsureBucket(ctx context.Context, namespace string) error { - location := storagecore.Location{Namespace: namespace, Key: "bucket-check"} - if err := location.Validate(); err != nil { - return err - } - exists, err := s.client.BucketExists(ctx, namespace) - if err != nil { - return fmt.Errorf("check S3 bucket %q: %w", namespace, err) - } - if exists { - return nil - } - if err := s.client.MakeBucket(ctx, namespace, minio.MakeBucketOptions{}); err != nil { - exists, checkErr := s.client.BucketExists(ctx, namespace) - if checkErr == nil && exists { - return nil - } - return fmt.Errorf("create S3 bucket %q: %w", namespace, err) - } - return nil -} - -// CheckBuckets verifies that every configured namespace remains reachable. -func (s *Store) CheckBuckets(ctx context.Context, namespaces ...string) error { - for _, namespace := range namespaces { - location := storagecore.Location{Namespace: namespace, Key: "health-check"} - if err := location.Validate(); err != nil { - return err - } - exists, err := s.client.BucketExists(ctx, namespace) - if err != nil { - return fmt.Errorf("check S3 bucket %q: %w", namespace, err) - } - if !exists { - return fmt.Errorf("check S3 bucket %q: bucket does not exist", namespace) - } - } - return nil -} - -func (s *Store) Put(ctx context.Context, location storagecore.Location, body io.Reader, size int64, opts ...storagecore.PutOption) (storagecore.Metadata, error) { - if err := location.Validate(); err != nil { - return storagecore.Metadata{}, err - } - if size < 0 { - return storagecore.Metadata{}, fmt.Errorf("%w: size cannot be negative", storagecore.ErrInvalidKey) - } - putOptions := storagecore.ResolvePutOptions(opts...) - userMetadata := make(map[string]string) - if putOptions.SHA256 != "" { - userMetadata["sha256"] = putOptions.SHA256 - } - s3Options := minio.PutObjectOptions{ - ContentType: putOptions.ContentType, UserMetadata: userMetadata, - } - if putOptions.CreateOnly { - // S3 conditional writes make CreateOnly atomic across processes. A - // preceding Stat has a race in which two writers can both overwrite the - // same create-only object key. - s3Options.SetMatchETagExcept("*") - } - info, err := s.client.PutObject(ctx, location.Namespace, location.Key, body, size, s3Options) - if err != nil { - return storagecore.Metadata{}, mapError("put", location, err) - } - return storagecore.Metadata{ - Location: location, Size: info.Size, SHA256: putOptions.SHA256, - ETag: info.ETag, Version: info.VersionID, ContentType: putOptions.ContentType, - }, nil -} - -func (s *Store) Open(ctx context.Context, location storagecore.Location, opts ...storagecore.OpenOption) (io.ReadCloser, error) { - if err := location.Validate(); err != nil { - return nil, err - } - // GetObject is lazy and may not report a missing object until the first - // read. Use StatObject for eager error semantics; calling Object.Stat after - // GetObject would discard the requested byte range in minio-go. - if _, err := s.client.StatObject(ctx, location.Namespace, location.Key, minio.StatObjectOptions{}); err != nil { - return nil, mapError("open", location, err) - } - openOptions := storagecore.ResolveOpenOptions(opts...) - getOptions := minio.GetObjectOptions{} - if openOptions.Offset < 0 || openOptions.Length < 0 { - return nil, fmt.Errorf("%w: range cannot be negative", storagecore.ErrInvalidKey) - } - if openOptions.Offset > 0 || openOptions.Length > 0 { - end := int64(0) - if openOptions.Length > 0 { - end = openOptions.Offset + openOptions.Length - 1 - } - if err := getOptions.SetRange(openOptions.Offset, end); err != nil { - return nil, fmt.Errorf("set S3 range: %w", err) - } - } - object, err := s.client.GetObject(ctx, location.Namespace, location.Key, getOptions) - if err != nil { - return nil, mapError("open", location, err) - } - return object, nil -} - -func (s *Store) Stat(ctx context.Context, location storagecore.Location) (storagecore.Metadata, error) { - if err := location.Validate(); err != nil { - return storagecore.Metadata{}, err - } - info, err := s.client.StatObject(ctx, location.Namespace, location.Key, minio.StatObjectOptions{}) - if err != nil { - return storagecore.Metadata{}, mapError("stat", location, err) - } - return storagecore.Metadata{ - Location: location, Size: info.Size, SHA256: info.Metadata.Get("X-Amz-Meta-Sha256"), - ETag: info.ETag, Version: info.VersionID, ContentType: info.ContentType, - }, nil -} - -func (s *Store) Delete(ctx context.Context, location storagecore.Location) error { - if err := location.Validate(); err != nil { - return err - } - if err := s.client.RemoveObject(ctx, location.Namespace, location.Key, minio.RemoveObjectOptions{}); err != nil { - mapped := mapError("delete", location, err) - if errors.Is(mapped, storagecore.ErrNotFound) { - return nil - } - return mapped - } - return nil -} - -func mapError(operation string, location storagecore.Location, err error) error { - response := minio.ToErrorResponse(err) - switch response.Code { - case "NoSuchKey", "NoSuchObject", "NoSuchBucket", "NotFound": - return fmt.Errorf("%s %s/%s: %w", operation, location.Namespace, location.Key, storagecore.ErrNotFound) - case "PreconditionFailed": - return fmt.Errorf("%s %s/%s: %w", operation, location.Namespace, location.Key, storagecore.ErrAlreadyExists) - default: - return fmt.Errorf("%s S3 object: %w", operation, err) - } -} - -var _ storagecore.Store = (*Store)(nil) diff --git a/pkg/storage/s3/s3_test.go b/pkg/storage/s3/s3_test.go deleted file mode 100644 index c3c9787..0000000 --- a/pkg/storage/s3/s3_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package s3 - -import ( - "bytes" - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - storagecore "github.com/mooncode-ai/mooncode/pkg/storage" - "github.com/stretchr/testify/require" -) - -func TestPutCreateOnlyUsesAtomicConditionalWrite(t *testing.T) { - var condition string - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - condition = request.Header.Get("If-None-Match") - writer.Header().Set("ETag", `"created"`) - writer.WriteHeader(http.StatusOK) - })) - defer server.Close() - - store, err := New(strings.TrimPrefix(server.URL, "http://"), "access", "secret", WithRegion("us-east-1")) - require.NoError(t, err) - body := []byte("immutable") - _, err = store.Put(context.Background(), storagecore.Location{Namespace: "artifacts", Key: "one.json"}, bytes.NewReader(body), int64(len(body)), storagecore.WithCreateOnly()) - require.NoError(t, err) - require.Equal(t, "*", condition) -} - -func TestPutCreateOnlyMapsPreconditionFailure(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.Header().Set("Content-Type", "application/xml") - writer.WriteHeader(http.StatusPreconditionFailed) - _, _ = writer.Write([]byte(`PreconditionFailedexists`)) - })) - defer server.Close() - - store, err := New(strings.TrimPrefix(server.URL, "http://"), "access", "secret", WithRegion("us-east-1")) - require.NoError(t, err) - body := []byte("immutable") - _, err = store.Put(context.Background(), storagecore.Location{Namespace: "artifacts", Key: "one.json"}, bytes.NewReader(body), int64(len(body)), storagecore.WithCreateOnly()) - require.Error(t, err) - require.True(t, errors.Is(err, storagecore.ErrAlreadyExists), err) -} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go deleted file mode 100644 index 4422885..0000000 --- a/pkg/storage/storage.go +++ /dev/null @@ -1,132 +0,0 @@ -// Package storage defines the portable binary storage contract used by MoonCode. -// It deliberately contains no tenant, authorization, or database concepts. -package storage - -import ( - "context" - "errors" - "fmt" - "io" - "path" - "strings" -) - -var ( - ErrNotFound = errors.New("storage: not found") - ErrAlreadyExists = errors.New("storage: already exists") - ErrInvalidKey = errors.New("storage: invalid key") -) - -type Location struct { - Namespace string - Key string -} - -func (l Location) Validate() error { - if strings.TrimSpace(l.Namespace) == "" { - return fmt.Errorf("%w: namespace is required", ErrInvalidKey) - } - if strings.ContainsAny(l.Namespace, "\\/\x00") || l.Namespace == "." || l.Namespace == ".." { - return fmt.Errorf("%w: invalid namespace", ErrInvalidKey) - } - if l.Key == "" || strings.ContainsRune(l.Key, '\x00') || strings.ContainsRune(l.Key, '\\') { - return fmt.Errorf("%w: invalid key", ErrInvalidKey) - } - if strings.HasPrefix(l.Key, "/") || path.Clean(l.Key) != l.Key || l.Key == "." || l.Key == ".." { - return fmt.Errorf("%w: key must be a clean relative slash path", ErrInvalidKey) - } - for _, segment := range strings.Split(l.Key, "/") { - if segment == "." || segment == ".." { - return fmt.Errorf("%w: key contains a traversal segment", ErrInvalidKey) - } - } - return nil -} - -type Metadata struct { - Location Location - Size int64 - SHA256 string - ETag string - Version string - ContentType string -} - -type Store interface { - Put(ctx context.Context, location Location, body io.Reader, size int64, opts ...PutOption) (Metadata, error) - Open(ctx context.Context, location Location, opts ...OpenOption) (io.ReadCloser, error) - Stat(ctx context.Context, location Location) (Metadata, error) - Delete(ctx context.Context, location Location) error -} - -type PutOptions struct { - ContentType string - SHA256 string - CreateOnly bool -} - -type PutOption func(*PutOptions) - -func WithContentType(contentType string) PutOption { - return func(options *PutOptions) { - options.ContentType = contentType - } -} - -func WithSHA256(checksum string) PutOption { - return func(options *PutOptions) { - options.SHA256 = checksum - } -} - -func WithCreateOnly() PutOption { - return func(options *PutOptions) { - options.CreateOnly = true - } -} - -func ResolvePutOptions(opts ...PutOption) PutOptions { - var options PutOptions - for _, option := range opts { - if option != nil { - option(&options) - } - } - return options -} - -type OpenOptions struct { - Offset int64 - Length int64 -} - -type OpenOption func(*OpenOptions) - -func WithRange(offset, length int64) OpenOption { - return func(options *OpenOptions) { - options.Offset = offset - options.Length = length - } -} - -func ResolveOpenOptions(opts ...OpenOption) OpenOptions { - var options OpenOptions - for _, option := range opts { - if option != nil { - option(&options) - } - } - return options -} - -type Wrapper func(Store) Store - -// Wrap applies wrappers in declaration order: the first wrapper is outermost. -func Wrap(store Store, wrappers ...Wrapper) Store { - for index := len(wrappers) - 1; index >= 0; index-- { - if wrappers[index] != nil { - store = wrappers[index](store) - } - } - return store -} diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go deleted file mode 100644 index d6a621d..0000000 --- a/pkg/storage/storage_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package storage - -import ( - "context" - "io" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestLocationValidate(t *testing.T) { - tests := []struct { - name string - location Location - wantErr bool - }{ - {name: "valid", location: Location{Namespace: "artifacts", Key: "workspaces/one/report.json"}}, - {name: "empty namespace", location: Location{Key: "object"}, wantErr: true}, - {name: "absolute", location: Location{Namespace: "artifacts", Key: "/etc/passwd"}, wantErr: true}, - {name: "traversal", location: Location{Namespace: "artifacts", Key: "../passwd"}, wantErr: true}, - {name: "unclean", location: Location{Namespace: "artifacts", Key: "a//b"}, wantErr: true}, - {name: "backslash", location: Location{Namespace: "artifacts", Key: `a\b`}, wantErr: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := test.location.Validate() - if test.wantErr { - require.ErrorIs(t, err, ErrInvalidKey) - return - } - require.NoError(t, err) - }) - } -} - -func TestWrapUsesDeclarationOrder(t *testing.T) { - var calls []string - base := stubStore{onStat: func() { calls = append(calls, "base") }} - wrap := func(name string) Wrapper { - return func(next Store) Store { - return stubStore{onStat: func() { - calls = append(calls, name+":before") - _, _ = next.Stat(context.Background(), Location{}) - calls = append(calls, name+":after") - }} - } - } - - _, _ = Wrap(base, wrap("first"), wrap("second")).Stat(context.Background(), Location{}) - require.Equal(t, []string{"first:before", "second:before", "base", "second:after", "first:after"}, calls) -} - -type stubStore struct { - onStat func() -} - -func (stubStore) Put(context.Context, Location, io.Reader, int64, ...PutOption) (Metadata, error) { - return Metadata{}, nil -} - -func (stubStore) Open(context.Context, Location, ...OpenOption) (io.ReadCloser, error) { - return nil, nil -} - -func (s stubStore) Stat(context.Context, Location) (Metadata, error) { - if s.onStat != nil { - s.onStat() - } - return Metadata{}, nil -} - -func (stubStore) Delete(context.Context, Location) error { return nil } diff --git a/pkg/storage/storagetest/contract.go b/pkg/storage/storagetest/contract.go deleted file mode 100644 index 516348b..0000000 --- a/pkg/storage/storagetest/contract.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package storagetest contains a reusable contract suite for storage.Store -// adapters. Adapter integration tests can call Run against their real backend. -package storagetest - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "sync" - "testing" - "time" - - "github.com/mooncode-ai/mooncode/pkg/storage" -) - -func Run(t *testing.T, store storage.Store, namespace string) { - t.Helper() - prefix := fmt.Sprintf("contract/%d", time.Now().UnixNano()) - - t.Run("put open stat range and delete", func(t *testing.T) { - location := storage.Location{Namespace: namespace, Key: prefix + "/object.bin"} - body := []byte("mooncode-storage-contract") - metadata, err := store.Put(t.Context(), location, bytes.NewReader(body), int64(len(body)), storage.WithContentType("application/octet-stream"), storage.WithSHA256("fixture-checksum"), storage.WithCreateOnly()) - if err != nil { - t.Fatalf("put: %v", err) - } - if metadata.Location != location { - t.Fatalf("put location = %#v, want %#v", metadata.Location, location) - } - stat, err := store.Stat(t.Context(), location) - if err != nil { - t.Fatalf("stat: %v", err) - } - if stat.Size != int64(len(body)) || stat.SHA256 != "fixture-checksum" { - t.Fatalf("stat metadata = %#v", stat) - } - reader, err := store.Open(t.Context(), location) - if err != nil { - t.Fatalf("open: %v", err) - } - contents, readErr := io.ReadAll(reader) - closeErr := reader.Close() - if readErr != nil || closeErr != nil || !bytes.Equal(contents, body) { - t.Fatalf("read = %q, read error = %v, close error = %v", contents, readErr, closeErr) - } - rangeReader, err := store.Open(t.Context(), location, storage.WithRange(4, 4)) - if err != nil { - t.Fatalf("open range: %v", err) - } - ranged, readErr := io.ReadAll(rangeReader) - closeErr = rangeReader.Close() - if readErr != nil || closeErr != nil || string(ranged) != "code" { - t.Fatalf("range = %q, read error = %v, close error = %v", ranged, readErr, closeErr) - } - if _, err := store.Put(t.Context(), location, bytes.NewReader(body), int64(len(body)), storage.WithCreateOnly()); !errors.Is(err, storage.ErrAlreadyExists) { - t.Fatalf("second create-only put error = %v, want ErrAlreadyExists", err) - } - if err := store.Delete(t.Context(), location); err != nil { - t.Fatalf("delete: %v", err) - } - if err := store.Delete(t.Context(), location); err != nil { - t.Fatalf("idempotent delete: %v", err) - } - if _, err := store.Stat(t.Context(), location); !errors.Is(err, storage.ErrNotFound) { - t.Fatalf("stat deleted error = %v, want ErrNotFound", err) - } - }) - - t.Run("atomic create only", func(t *testing.T) { - location := storage.Location{Namespace: namespace, Key: prefix + "/atomic.bin"} - start := make(chan struct{}) - errorsChannel := make(chan error, 2) - var group sync.WaitGroup - for _, value := range []string{"first", "second"} { - group.Add(1) - go func(value string) { - defer group.Done() - <-start - _, err := store.Put(t.Context(), location, bytes.NewBufferString(value), int64(len(value)), storage.WithCreateOnly()) - errorsChannel <- err - }(value) - } - close(start) - group.Wait() - close(errorsChannel) - succeeded, alreadyExists := 0, 0 - for err := range errorsChannel { - switch { - case err == nil: - succeeded++ - case errors.Is(err, storage.ErrAlreadyExists): - alreadyExists++ - default: - t.Fatalf("create-only error: %v", err) - } - } - if succeeded != 1 || alreadyExists != 1 { - t.Fatalf("successes = %d, already-exists = %d", succeeded, alreadyExists) - } - if err := store.Delete(t.Context(), location); err != nil { - t.Fatalf("delete atomic fixture: %v", err) - } - }) - - t.Run("validation and cancellation", func(t *testing.T) { - invalid := storage.Location{Namespace: namespace, Key: "../escape"} - if _, err := store.Stat(t.Context(), invalid); !errors.Is(err, storage.ErrInvalidKey) { - t.Fatalf("invalid key error = %v, want ErrInvalidKey", err) - } - ctx, cancel := context.WithCancel(t.Context()) - cancel() - location := storage.Location{Namespace: namespace, Key: prefix + "/cancelled.bin"} - if _, err := store.Put(ctx, location, bytes.NewBufferString("cancelled"), 9); err == nil { - t.Fatal("cancelled put unexpectedly succeeded") - } - }) -} diff --git a/scripts/e2e/live-smoke.sh b/scripts/e2e/live-smoke.sh new file mode 100755 index 0000000..eeabfcc --- /dev/null +++ b/scripts/e2e/live-smoke.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +base_url="${MOONCODE_E2E_BASE_URL:-}" +session_cookie="${MOONCODE_E2E_SESSION_COOKIE:-}" + +if [[ -z "${base_url}" ]]; then + echo "MOONCODE_E2E_BASE_URL is required" >&2 + exit 2 +fi +if [[ "${base_url}" != http://* && "${base_url}" != https://* ]]; then + echo "MOONCODE_E2E_BASE_URL must use http or https" >&2 + exit 2 +fi +if [[ -z "${session_cookie}" ]]; then + echo "MOONCODE_E2E_SESSION_COOKIE is required" >&2 + exit 2 +fi + +base_url="${base_url%/}" + +check_get() { + local path="$1" + local status + + if ! status="$(curl \ + --silent \ + --show-error \ + --request GET \ + --connect-timeout 10 \ + --max-time 30 \ + --proto '=http,https' \ + --header "Cookie: ${session_cookie}" \ + --output /dev/null \ + --write-out '%{http_code}' \ + "${base_url}${path}")"; then + echo "GET ${path} failed" >&2 + return 1 + fi + + if [[ "${status}" != 2?? ]]; then + echo "GET ${path} returned HTTP ${status}, expected 2xx" >&2 + return 1 + fi + + echo "ok GET ${path} (${status})" +} + +check_get "/healthz" +check_get "/api/v1/session" +check_get "/api/openapi.yaml" diff --git a/sql/queries/analysis.sql b/sql/queries/analysis.sql new file mode 100644 index 0000000..589b224 --- /dev/null +++ b/sql/queries/analysis.sql @@ -0,0 +1,139 @@ +-- name: CreateAnalysisRun :one +INSERT INTO analysis_runs ( + id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, + dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'queued') +ON CONFLICT (workspace_id, idempotency_key, attempt) DO NOTHING +RETURNING *; + +-- name: LockAnalysisSnapshot :one +SELECT snapshot.source_state +FROM commit_snapshots AS snapshot +JOIN repositories AS repository ON repository.id = snapshot.repository_id +WHERE snapshot.id = $1 + AND snapshot.repository_id = $2 + AND repository.workspace_id = $3 +FOR UPDATE OF snapshot; + +-- name: GetAnalysisRunByAttempt :one +SELECT * FROM analysis_runs +WHERE workspace_id=$1 AND idempotency_key=$2 AND attempt=$3; + +-- name: GetAnalysisRun :one +SELECT * FROM analysis_runs WHERE id=$1 AND workspace_id=$2; + +-- name: ListAnalysisRuns :many +SELECT * FROM analysis_runs +WHERE repository_id=sqlc.arg('repository_id') + AND workspace_id=sqlc.arg('workspace_id') + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) +ORDER BY created_at DESC, id DESC +LIMIT sqlc.arg('limit'); + +-- name: GetAnalysisRunWork :one +SELECT * FROM analysis_runs WHERE id=$1; + +-- name: StartAnalysisRun :one +UPDATE analysis_runs SET status='running', stage='authorize', started_at=now() +WHERE id=$1 AND status='queued' RETURNING *; + +-- name: SetAnalysisRunStage :execrows +UPDATE analysis_runs SET stage=$2 +WHERE id=$1 AND status='running'; + +-- name: FinishAnalysisRun :one +UPDATE analysis_runs +SET status='succeeded', report_id=$2, failed_stage=NULL, error_code=NULL, + error_message=NULL, retryable=false, stage='complete', finished_at=$3 +WHERE id=$1 AND status='running' RETURNING *; + +-- name: FailAnalysisRun :one +UPDATE analysis_runs +SET status='failed', stage=$2, failed_stage=$2, error_code=$3, error_message=$4, + retryable=$5, finished_at=now() +WHERE id=$1 AND status IN ('queued','running') RETURNING *; + +-- name: CancelAnalysisRun :one +UPDATE analysis_runs SET status='cancelled', stage='cancelled', retryable=true, finished_at=now() +WHERE id=$1 AND workspace_id=$2 AND status IN ('queued','running') +RETURNING workflow_run_id; + +-- name: SetAnalysisWorkflowID :exec +UPDATE analysis_runs SET workflow_run_id=$2 WHERE id=$1; + +-- name: CreateAnalysisReport :one +INSERT INTO analysis_reports ( + id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha, + source_ref, commit_author_name, commit_authored_at, commit_title, + dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment, + started_at, finished_at, duration_ms, result, raw_artifact +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) +RETURNING *; + +-- name: AnalysisClock :one +SELECT clock_timestamp()::timestamptz AS current_time; + +-- name: GetAnalysisReport :one +SELECT * FROM analysis_reports WHERE id=$1 AND workspace_id=$2; + +-- name: GetAnalysisReportByRun :one +SELECT * FROM analysis_reports WHERE analysis_run_id=$1; + +-- name: ListAnalysisProfiles :many +SELECT p.*, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at +FROM analysis_profiles p +JOIN analysis_profile_versions v + ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version +WHERE p.workspace_id=sqlc.arg('workspace_id') AND p.archived_at IS NULL + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (p.created_at, p.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) +ORDER BY p.created_at DESC, p.id DESC +LIMIT sqlc.arg('limit'); + +-- name: GetAnalysisProfile :one +SELECT p.*, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at +FROM analysis_profiles p +JOIN analysis_profile_versions v + ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version +WHERE p.id=$1 AND p.workspace_id=$2 AND p.archived_at IS NULL; + +-- name: GetDefaultAnalysisProfile :one +SELECT p.*, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at +FROM analysis_profiles p +JOIN analysis_profile_versions v + ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version +WHERE p.workspace_id=$1 AND p.archived_at IS NULL +ORDER BY p.created_at, p.id +LIMIT 1; + +-- name: CreateAnalysisProfile :one +INSERT INTO analysis_profiles (id, workspace_id, name, current_version, created_by) +VALUES ($1,$2,$3,1,$4) RETURNING *; + +-- name: CreateAnalysisProfileVersion :one +INSERT INTO analysis_profile_versions (id, workspace_id, profile_id, version, dimension_key, definition, created_by) +VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *; + +-- name: AdvanceAnalysisProfile :one +UPDATE analysis_profiles +SET name=$3, current_version=current_version+1, updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND current_version=$4 AND archived_at IS NULL +RETURNING *; + +-- name: ArchiveAnalysisProfile :execrows +UPDATE analysis_profiles SET archived_at=now(), updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND archived_at IS NULL; + +-- name: LockActiveAnalysisProfiles :many +SELECT id FROM analysis_profiles +WHERE workspace_id=$1 AND archived_at IS NULL +ORDER BY id +FOR UPDATE; +-- name: CountActiveAnalysisRuns :one +SELECT count(*) FROM analysis_runs WHERE workspace_id=$1 AND status IN ('queued','running'); diff --git a/sql/queries/audit.sql b/sql/queries/audit.sql new file mode 100644 index 0000000..7fba724 --- /dev/null +++ b/sql/queries/audit.sql @@ -0,0 +1,18 @@ +-- name: CreateAuditEvent :exec +INSERT INTO audit_events ( + id, + workspace_id, + actor_user_id, + action, + resource_type, + resource_id, + metadata +) VALUES ( + sqlc.arg(id), + sqlc.narg(workspace_id), + sqlc.narg(actor_user_id), + sqlc.arg(action), + sqlc.arg(resource_type), + sqlc.narg(resource_id), + sqlc.arg(metadata) +); diff --git a/sql/queries/channel_commands.sql b/sql/queries/channel_commands.sql new file mode 100644 index 0000000..fdb0899 --- /dev/null +++ b/sql/queries/channel_commands.sql @@ -0,0 +1,68 @@ +-- name: GetChannelExternalIdentity :one +SELECT user_id FROM channel_external_identities +WHERE channel_id=$1 AND sender_canonical_id=$2; + +-- name: CheckActiveChannelVersion :one +SELECT config_version FROM channels +WHERE id=$1 AND workspace_id=$2 AND enabled AND config_version=$3; + +-- name: CreateChannelIdentityLink :one +INSERT INTO channel_identity_links ( + id, workspace_id, channel_id, channel_version, sender_canonical_id, token_hash, expires_at +) +SELECT sqlc.arg('id'), sqlc.arg('workspace_id'), sqlc.arg('channel_id'), + sqlc.arg('channel_version'), sqlc.arg('sender_canonical_id'), + sqlc.arg('token_hash'), sqlc.arg('expires_at') +FROM channels +WHERE id=sqlc.arg('channel_id') + AND workspace_id=sqlc.arg('workspace_id') + AND enabled + AND config_version=sqlc.arg('channel_version') +RETURNING *; + +-- name: GetChannelIdentityLink :one +SELECT l.* FROM channel_identity_links l +JOIN channels c ON c.id=l.channel_id +WHERE l.token_hash=$1 + AND l.consumed_at IS NULL + AND l.expires_at > now() + AND c.enabled + AND c.config_version=l.channel_version; + +-- name: ConsumeChannelIdentityLink :execrows +UPDATE channel_identity_links l SET consumed_at=now() +FROM channels c +WHERE l.id=$1 + AND l.consumed_at IS NULL + AND l.expires_at > now() + AND c.id=l.channel_id + AND c.enabled + AND c.config_version=l.channel_version; + +-- name: UpsertChannelExternalIdentity :exec +INSERT INTO channel_external_identities ( + id, workspace_id, channel_id, sender_canonical_id, user_id +) VALUES ($1,$2,$3,$4,$5) +ON CONFLICT (channel_id, sender_canonical_id) DO UPDATE +SET user_id=excluded.user_id; + +-- name: GetConversationIDByExternal :one +SELECT conversation.id FROM conversations AS conversation +JOIN channels AS channel ON channel.id=conversation.channel_id +WHERE conversation.channel_id=sqlc.arg('channel_id') + AND conversation.external_id=sqlc.arg('external_id') + AND channel.workspace_id=sqlc.arg('workspace_id') + AND channel.enabled + AND channel.config_version=sqlc.arg('channel_version'); + +-- name: GetConversationBinding :one +SELECT repository_id FROM conversation_bindings WHERE conversation_id=$1; + +-- name: UpsertConversationBinding :exec +INSERT INTO conversation_bindings (conversation_id, workspace_id, repository_id, bound_by) +VALUES ($1,$2,$3,$4) +ON CONFLICT (conversation_id) DO UPDATE +SET repository_id=excluded.repository_id, bound_by=excluded.bound_by, updated_at=now(); + +-- name: DeleteConversationBinding :execrows +DELETE FROM conversation_bindings WHERE conversation_id=$1; diff --git a/sql/queries/channels.sql b/sql/queries/channels.sql index fd7b587..92c13c4 100644 --- a/sql/queries/channels.sql +++ b/sql/queries/channels.sql @@ -1,142 +1,124 @@ --- name: CreateChannelInstance :one -INSERT INTO channel_instances (id, workspace_id, type, name, enabled, config, secret_ref) -VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(type), sqlc.arg(name), false, - sqlc.arg(config), sqlc.narg(secret_ref)) -RETURNING *; - --- name: ListChannelInstances :many -SELECT * FROM channel_instances -WHERE workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL -ORDER BY created_at DESC, id DESC; - --- name: GetChannelInstance :one -SELECT * FROM channel_instances -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL; - --- name: ListEnabledChannelInstances :many -SELECT * FROM channel_instances -WHERE enabled = true AND deleted_at IS NULL -ORDER BY id; +-- name: ListChannels :many +SELECT * FROM channels +WHERE workspace_id=sqlc.arg('workspace_id') + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) +ORDER BY created_at DESC, id DESC +LIMIT sqlc.arg('limit'); + +-- name: CreateChannel :one +INSERT INTO channels (id, workspace_id, type, name, enabled, secret_ciphertext, secret_nonce, key_version, config) +VALUES ($1,$2,$3,$4,false,$5,$6,$7,$8) RETURNING *; + +-- name: GetChannel :one +SELECT * FROM channels WHERE id=$1 AND workspace_id=$2; + +-- name: ListChannelSubscriptions :many +SELECT channel_id, event_type FROM channel_subscriptions +WHERE workspace_id=$1 +ORDER BY channel_id, event_type; + +-- name: ListSubscribedChannels :many +SELECT c.* FROM channels c +JOIN channel_subscriptions subscription ON subscription.channel_id=c.id AND subscription.workspace_id=c.workspace_id +WHERE c.workspace_id=$1 AND c.enabled AND subscription.event_type=$2 +ORDER BY c.created_at; + +-- name: DeleteChannelSubscriptions :exec +DELETE FROM channel_subscriptions WHERE workspace_id=$1 AND channel_id=$2; + +-- name: CreateChannelSubscription :exec +INSERT INTO channel_subscriptions (id, workspace_id, channel_id, event_type) +VALUES ($1,$2,$3,$4); + +-- name: ListRuntimeChannels :many +SELECT * FROM channels +WHERE enabled AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND key_version IS NOT NULL +ORDER BY created_at; + +-- name: LockChannelRuntime :one +SELECT config_version FROM channels +WHERE id=$1 AND workspace_id=$2 AND enabled +FOR KEY SHARE; + +-- name: MarkChannelRuntimeConnected :execrows +UPDATE channels SET runtime_status='connected', last_connected_at=now(), last_error_message=NULL, updated_at=now() +WHERE id=$1 AND enabled AND config_version=$2; + +-- name: MarkChannelRuntimeError :execrows +UPDATE channels SET runtime_status='error', last_error_message=$3, updated_at=now() +WHERE id=$1 AND enabled AND config_version=$2; + +-- name: UpdateChannel :one +UPDATE channels SET name=$3, config=$4, config_version=config_version+1, + runtime_status=CASE WHEN enabled THEN 'starting' ELSE 'disabled' END, + last_error_message=NULL, updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND config_version=$5 RETURNING *; + +-- name: RotateChannelCredential :one +UPDATE channels SET secret_ciphertext=$3, secret_nonce=$4, key_version=$5, + config_version=config_version+1, + runtime_status=CASE WHEN enabled THEN 'starting' ELSE 'disabled' END, + last_error_message=NULL, updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND config_version=$6 RETURNING *; -- name: SetChannelEnabled :one -UPDATE channel_instances SET enabled = sqlc.arg(enabled), config_version = config_version + 1, - updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL -RETURNING *; - --- name: SoftDeleteChannel :one -UPDATE channel_instances -SET enabled = false, secret_ref = NULL, deleted_at = now(), config_version = config_version + 1, updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL -RETURNING *; - --- name: UpdateChannelInstance :one -UPDATE channel_instances SET name = sqlc.arg(name), config = sqlc.arg(config), - secret_ref = sqlc.narg(secret_ref), config_version = config_version + 1, updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) - AND config_version = sqlc.arg(expected_version) AND deleted_at IS NULL -RETURNING *; - --- name: AcquireChannelLease :one -INSERT INTO channel_leases (channel_instance_id, owner, lease_until, fencing_token) -VALUES (sqlc.arg(channel_instance_id), sqlc.arg(owner), sqlc.arg(lease_until), 1) -ON CONFLICT (channel_instance_id) DO UPDATE -SET owner = EXCLUDED.owner, lease_until = EXCLUDED.lease_until, - fencing_token = CASE WHEN channel_leases.owner = EXCLUDED.owner - THEN channel_leases.fencing_token ELSE channel_leases.fencing_token + 1 END, - updated_at = now() -WHERE channel_leases.owner = EXCLUDED.owner OR channel_leases.lease_until < now() -RETURNING *; - --- name: RenewChannelLease :execrows -UPDATE channel_leases SET lease_until = sqlc.arg(lease_until), updated_at = now() -WHERE channel_instance_id = sqlc.arg(channel_instance_id) AND owner = sqlc.arg(owner) - AND fencing_token = sqlc.arg(fencing_token); - --- name: ReleaseChannelLease :exec -DELETE FROM channel_leases -WHERE channel_instance_id = sqlc.arg(channel_instance_id) AND owner = sqlc.arg(owner) - AND fencing_token = sqlc.arg(fencing_token); - --- name: SetChannelRuntimeStatus :execrows -INSERT INTO channel_runtime_status ( - channel_instance_id, state, backend_instance_id, fencing_token, - last_connected_at, last_error_code, last_error_message -) SELECT - lease.channel_instance_id, sqlc.arg(state), sqlc.arg(backend_instance_id), - sqlc.arg(fencing_token), sqlc.narg(last_connected_at), sqlc.arg(last_error_code), - sqlc.arg(last_error_message) -FROM channel_leases AS lease -WHERE lease.channel_instance_id = sqlc.arg(channel_instance_id) - AND lease.owner = sqlc.arg(backend_instance_id) - AND lease.fencing_token = sqlc.arg(fencing_token) - AND lease.lease_until > now() -ON CONFLICT (channel_instance_id) DO UPDATE SET - state = EXCLUDED.state, backend_instance_id = EXCLUDED.backend_instance_id, - fencing_token = EXCLUDED.fencing_token, - last_connected_at = COALESCE(EXCLUDED.last_connected_at, channel_runtime_status.last_connected_at), - last_error_code = EXCLUDED.last_error_code, - last_error_message = EXCLUDED.last_error_message, updated_at = now() -WHERE channel_runtime_status.fencing_token <= EXCLUDED.fencing_token; - --- name: GetChannelRuntimeStatus :one -SELECT * FROM channel_runtime_status WHERE channel_instance_id = sqlc.arg(channel_instance_id); - --- name: InsertInboxEvent :one -INSERT INTO inbox_events (channel_instance_id, external_event_id, payload_hash) -VALUES (sqlc.arg(channel_instance_id), sqlc.arg(external_event_id), sqlc.arg(payload_hash)) -ON CONFLICT DO NOTHING -RETURNING external_event_id; - --- name: UpsertIMConversation :one -INSERT INTO im_conversations (id, workspace_id, channel_instance_id, external_id, type, title) -VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_instance_id), - sqlc.arg(external_id), sqlc.arg(type), sqlc.arg(title)) -ON CONFLICT (channel_instance_id, external_id) DO UPDATE -SET type = EXCLUDED.type, title = CASE WHEN EXCLUDED.title = '' THEN im_conversations.title ELSE EXCLUDED.title END, - updated_at = now() -RETURNING *; - --- name: UpsertIMSender :one -INSERT INTO im_senders (id, workspace_id, channel_type, canonical_id, display_name) -VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_type), - sqlc.arg(canonical_id), sqlc.arg(display_name)) -ON CONFLICT (workspace_id, canonical_id) DO UPDATE -SET display_name = CASE WHEN EXCLUDED.display_name = '' THEN im_senders.display_name ELSE EXCLUDED.display_name END, - updated_at = now() -RETURNING *; - --- name: InsertIMMessage :one -INSERT INTO im_messages ( - id, workspace_id, channel_instance_id, conversation_id, sender_id, - external_message_id, content, occurred_at -) VALUES ( - sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_instance_id), - sqlc.arg(conversation_id), sqlc.arg(sender_id), sqlc.arg(external_message_id), - sqlc.arg(content), sqlc.arg(occurred_at) +UPDATE channels SET enabled=$3, runtime_status=CASE WHEN $3 THEN 'starting' ELSE 'disabled' END, + last_error_message=CASE WHEN $3 THEN NULL ELSE last_error_message END, updated_at=now() +WHERE id=$1 AND workspace_id=$2 RETURNING *; + +-- name: DeleteChannel :execrows +DELETE FROM channels WHERE id=$1 AND workspace_id=$2; + +-- name: UpsertConversation :one +INSERT INTO conversations (id, workspace_id, channel_id, external_id, type, title) +VALUES ($1,$2,$3,$4,$5,$6) +ON CONFLICT (channel_id, external_id) DO UPDATE +SET type=excluded.type, title=excluded.title +RETURNING id; + +-- name: InsertInboundMessage :execrows +INSERT INTO messages ( + id, workspace_id, channel_id, conversation_id, external_id, + sender_canonical_id, sender_display_name, content, occurred_at ) -ON CONFLICT (channel_instance_id, external_message_id) DO NOTHING -RETURNING *; - --- name: ListIMMessages :many +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) +ON CONFLICT (channel_id, external_id) DO NOTHING; + +-- name: ListConversations :many +SELECT c.id, c.workspace_id, c.channel_id, c.external_id, c.type, c.title, c.created_at, + ch.name AS channel_name, ch.type AS channel_type +FROM conversations c JOIN channels ch ON ch.id=c.channel_id +WHERE c.workspace_id=sqlc.arg('workspace_id') + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (c.created_at, c.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) +ORDER BY c.created_at DESC, c.id DESC +LIMIT sqlc.arg('limit'); + +-- name: ListMessages :many SELECT m.*, c.external_id AS conversation_external_id, c.type AS conversation_type, - s.canonical_id AS sender_canonical_id, s.display_name AS sender_display_name, - ci.name AS channel_name, ci.type AS channel_type -FROM im_messages m -JOIN im_conversations c ON c.id = m.conversation_id AND c.workspace_id = m.workspace_id -JOIN im_senders s ON s.id = m.sender_id AND s.workspace_id = m.workspace_id -JOIN channel_instances ci ON ci.id = m.channel_instance_id AND ci.workspace_id = m.workspace_id -WHERE m.workspace_id = sqlc.arg(workspace_id) - AND (sqlc.narg(channel_instance_id)::uuid IS NULL OR m.channel_instance_id = sqlc.narg(channel_instance_id)) - AND (sqlc.narg(conversation_id)::uuid IS NULL OR m.conversation_id = sqlc.narg(conversation_id)) - AND (sqlc.narg(before_time)::timestamptz IS NULL OR (m.occurred_at, m.id) < (sqlc.narg(before_time), sqlc.narg(before_id)::uuid)) + ch.name AS channel_name, ch.type AS channel_type +FROM messages m +JOIN conversations c ON c.id=m.conversation_id +JOIN channels ch ON ch.id=m.channel_id +WHERE m.workspace_id=sqlc.arg('workspace_id') + AND (sqlc.narg('channel_id')::uuid IS NULL OR m.channel_id=sqlc.narg('channel_id')) + AND (sqlc.narg('conversation_id')::uuid IS NULL OR m.conversation_id=sqlc.narg('conversation_id')) + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (m.occurred_at, m.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) ORDER BY m.occurred_at DESC, m.id DESC -LIMIT sqlc.arg(page_size); - --- name: ListIMConversations :many -SELECT c.*, ci.name AS channel_name, ci.type AS channel_type -FROM im_conversations c -JOIN channel_instances ci ON ci.id = c.channel_instance_id AND ci.workspace_id = c.workspace_id -WHERE c.workspace_id = sqlc.arg(workspace_id) -ORDER BY c.updated_at DESC, c.id DESC -LIMIT sqlc.arg(page_size); +LIMIT sqlc.arg('limit'); + +-- name: GetWorkspaceOverview :one +SELECT + (SELECT count(*) FROM repositories rr WHERE rr.workspace_id=$1 AND rr.status <> 'deleted') AS repository_count, + (SELECT count(*) FROM repositories rr WHERE rr.workspace_id=$1 AND rr.status <> 'deleted' AND rr.current_snapshot_id IS NULL) AS repository_without_code_count, + (SELECT count(*) FROM repository_operations o JOIN repositories r ON r.id=o.repository_id WHERE r.workspace_id=$1 AND o.status='failed' AND o.created_at > now()-interval '7 days') AS recent_failed_sync_count, + (SELECT count(*) FROM analysis_runs ar WHERE ar.workspace_id=$1 AND ar.status IN ('queued','running')) AS active_analysis_count, + (SELECT count(*) FROM channels cc WHERE cc.workspace_id=$1 AND cc.enabled) AS active_channel_count; diff --git a/sql/queries/identity.sql b/sql/queries/identity.sql index 4ada781..a895a99 100644 --- a/sql/queries/identity.sql +++ b/sql/queries/identity.sql @@ -1,132 +1,103 @@ --- name: UpsertUser :one -INSERT INTO users ( - id, issuer, external_subject, username, email, display_name, last_seen_at -) VALUES ( - sqlc.arg(id), sqlc.arg(issuer), sqlc.arg(external_subject), - sqlc.arg(username), sqlc.arg(email), sqlc.arg(display_name), now() -) -ON CONFLICT (issuer, external_subject) DO UPDATE SET - username = EXCLUDED.username, - email = EXCLUDED.email, - display_name = EXCLUDED.display_name, - last_seen_at = now(), - updated_at = now() +-- name: GetUserByIdentity :one +SELECT u.* FROM users u +JOIN oauth_identities oi ON oi.user_id = u.id +WHERE oi.issuer = $1 AND oi.subject = $2; + +-- name: CreateUser :one +INSERT INTO users (id, display_name, username, email, status) +VALUES ($1, $2, $3, $4, $5) RETURNING *; --- name: GetUserByExternalIdentity :one -SELECT * FROM users -WHERE issuer = sqlc.arg(issuer) AND external_subject = sqlc.arg(external_subject); +-- name: CreateOAuthIdentity :exec +INSERT INTO oauth_identities (issuer, subject, user_id) +VALUES ($1, $2, $3); --- name: GetUserForUpdate :one -SELECT * FROM users -WHERE id = sqlc.arg(id) -FOR UPDATE; +-- name: UpdateUserProfile :one +UPDATE users SET display_name = $2, username = $3, email = $4, updated_at = now() +WHERE id = $1 RETURNING *; -- name: ActivateUser :one -UPDATE users SET - status = 'active', - terms_version = sqlc.arg(terms_version), - privacy_version = sqlc.arg(privacy_version), - activated_at = COALESCE(activated_at, now()), - agreements_accepted_at = now(), - updated_at = now() -WHERE id = sqlc.arg(id) AND status IN ('pending', 'active') +UPDATE users +SET status = 'active', accepted_terms_at = now(), accepted_privacy_at = now(), updated_at = now() +WHERE id = $1 AND status = 'pending' RETURNING *; +-- name: GetUser :one +SELECT * FROM users WHERE id = $1; + -- name: CreateWorkspace :one -INSERT INTO workspaces (id, name, slug, kind, created_by) -VALUES (sqlc.arg(id), sqlc.arg(name), sqlc.arg(slug), sqlc.arg(kind), sqlc.arg(created_by)) +INSERT INTO workspaces (id, name, slug, created_by) +VALUES ($1, $2, $3, $4) RETURNING *; --- name: AddWorkspaceMember :one +-- name: CreateRetentionCleanupSchedule :exec +INSERT INTO retention_cleanup_schedules (workspace_id) +VALUES ($1); + +-- name: AddWorkspaceMember :exec INSERT INTO workspace_members (workspace_id, user_id, role) -VALUES (sqlc.arg(workspace_id), sqlc.arg(user_id), sqlc.arg(role)) -ON CONFLICT (workspace_id, user_id) DO NOTHING -RETURNING *; +VALUES ($1, $2, $3) +ON CONFLICT (workspace_id, user_id) DO NOTHING; --- name: GetPersonalWorkspaceForUser :one -SELECT w.* -FROM workspaces w -JOIN workspace_members wm ON wm.workspace_id = w.id -WHERE wm.user_id = sqlc.arg(user_id) - AND w.kind = 'personal' - AND w.deleted_at IS NULL -ORDER BY w.created_at -LIMIT 1; - --- name: ListWorkspacesForUser :many -SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, wm.role -FROM workspaces w +-- name: GetWorkspaceMembership :one +SELECT w.*, wm.role FROM workspaces w JOIN workspace_members wm ON wm.workspace_id = w.id -WHERE wm.user_id = sqlc.arg(user_id) - AND w.deleted_at IS NULL -ORDER BY w.created_at, w.id; +JOIN users u ON u.id = wm.user_id AND u.status = 'active' +WHERE w.id = $1 AND wm.user_id = $2; --- name: GetWorkspaceMembership :one -SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, wm.role -FROM workspaces w +-- name: ListUserWorkspaces :many +SELECT w.*, wm.role FROM workspaces w JOIN workspace_members wm ON wm.workspace_id = w.id -WHERE w.id = sqlc.arg(workspace_id) - AND wm.user_id = sqlc.arg(user_id) - AND w.deleted_at IS NULL; +WHERE wm.user_id = $1 +ORDER BY w.created_at; -- name: UpdateWorkspace :one -UPDATE workspaces SET name = sqlc.arg(name), updated_at = now() -WHERE id = sqlc.arg(id) AND deleted_at IS NULL +UPDATE workspaces +SET name = COALESCE(sqlc.narg('name')::text, name), + report_retention_days = COALESCE(sqlc.narg('report_retention_days')::integer, report_retention_days), + updated_at = clock_timestamp() +WHERE id = sqlc.arg('id') RETURNING *; -- name: ListWorkspaceMembers :many -SELECT wm.workspace_id, wm.user_id, wm.role, wm.created_at, wm.updated_at, - u.username, u.email, u.display_name -FROM workspace_members AS wm -JOIN users AS u ON u.id = wm.user_id -WHERE wm.workspace_id = sqlc.arg(workspace_id) -ORDER BY wm.created_at, wm.user_id; - --- name: UpsertWorkspaceMember :exec -INSERT INTO workspace_members (workspace_id, user_id, role) -VALUES (sqlc.arg(workspace_id), sqlc.arg(user_id), sqlc.arg(role)) -ON CONFLICT (workspace_id, user_id) DO UPDATE -SET role = EXCLUDED.role, updated_at = now(); - --- name: DeleteWorkspaceMember :execrows -DELETE FROM workspace_members -WHERE workspace_id = sqlc.arg(workspace_id) AND user_id = sqlc.arg(user_id); +SELECT u.id, u.username, u.email, u.display_name, wm.role, wm.created_at +FROM workspace_members wm JOIN users u ON u.id = wm.user_id +WHERE wm.workspace_id = sqlc.arg('workspace_id') + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (wm.created_at, wm.user_id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) +ORDER BY wm.created_at DESC, wm.user_id DESC +LIMIT sqlc.arg('limit'); + +-- name: RemoveWorkspaceMember :execrows +DELETE FROM workspace_members WHERE workspace_id = $1 AND user_id = $2 AND role <> 'owner'; -- name: CreateWorkspaceInvitation :one -INSERT INTO workspace_invitations ( - id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at -) VALUES ( - sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(email_normalized), sqlc.arg(role), - sqlc.arg(token_hash), sqlc.arg(invited_by), sqlc.arg(expires_at) -) +INSERT INTO workspace_invitations (id, workspace_id, email, token_hash, created_by, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: ListWorkspaceInvitations :many SELECT * FROM workspace_invitations -WHERE workspace_id = sqlc.arg(workspace_id) -ORDER BY created_at DESC, id DESC; +WHERE workspace_id = sqlc.arg('workspace_id') + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) +ORDER BY created_at DESC, id DESC +LIMIT sqlc.arg('limit'); + +-- name: RevokeWorkspaceInvitation :execrows +UPDATE workspace_invitations SET revoked_at = now() +WHERE id = $1 AND workspace_id = $2 AND accepted_at IS NULL AND revoked_at IS NULL; --- name: GetWorkspaceInvitationForUpdate :one +-- name: GetInvitationByTokenHash :one SELECT * FROM workspace_invitations -WHERE token_hash = sqlc.arg(token_hash) -FOR UPDATE; +WHERE token_hash = $1 AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now(); -- name: AcceptWorkspaceInvitation :execrows -UPDATE workspace_invitations SET - accepted_at = now(), - accepted_by = sqlc.arg(accepted_by), - updated_at = now() -WHERE id = sqlc.arg(id) AND accepted_at IS NULL AND revoked_at IS NULL; - --- name: RevokeWorkspaceInvitation :execrows -UPDATE workspace_invitations SET revoked_at = now(), updated_at = now() -WHERE id = sqlc.arg(id) AND accepted_at IS NULL AND revoked_at IS NULL; - --- name: AppendAccountAudit :exec -INSERT INTO account_audit_logs ( - id, user_id, actor_user_id, action, result, provider, request_id, metadata, occurred_at -) VALUES ( - sqlc.arg(id), sqlc.arg(user_id), sqlc.narg(actor_user_id), sqlc.arg(action), - sqlc.arg(result), sqlc.arg(provider), sqlc.arg(request_id), sqlc.arg(metadata), sqlc.arg(occurred_at) -); +UPDATE workspace_invitations SET accepted_at = now() +WHERE id = $1 AND accepted_at IS NULL AND revoked_at IS NULL; +-- name: LockWorkspaceQuota :one +SELECT id FROM workspaces WHERE id=$1 FOR UPDATE; diff --git a/sql/queries/jobs.sql b/sql/queries/jobs.sql deleted file mode 100644 index 0debe57..0000000 --- a/sql/queries/jobs.sql +++ /dev/null @@ -1,113 +0,0 @@ --- name: CreateJob :one -INSERT INTO jobs ( - id, workspace_id, type, payload, status, idempotency_key, - max_attempts, run_after, trace_context -) VALUES ( - sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(type), sqlc.arg(payload), 'queued', - sqlc.arg(idempotency_key), sqlc.arg(max_attempts), sqlc.arg(run_after), sqlc.arg(trace_context) -) -RETURNING *; - --- name: GetJob :one -SELECT * FROM jobs WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id); - --- name: CancelJob :one -UPDATE jobs -SET status = 'cancelled', lease_owner = NULL, lease_until = NULL, - last_error_code = 'job.cancelled', last_error_message = 'Cancelled by user', - finished_at = now(), updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) - AND status IN ('queued', 'running') -RETURNING *; - --- name: CancelActiveRepositorySyncJob :one -UPDATE jobs -SET status = 'cancelled', lease_owner = NULL, lease_until = NULL, - last_error_code = 'job.cancelled', last_error_message = 'Cancelled by user', - finished_at = now(), updated_at = now() -WHERE workspace_id = sqlc.arg(workspace_id) AND type = 'repository.sync' - AND payload->>'repositoryId' = sqlc.arg(repository_id)::text - AND status IN ('queued', 'running') -RETURNING *; - --- name: ListJobQueueDepths :many -SELECT type, status, count(*)::bigint AS depth -FROM jobs -WHERE type = ANY(sqlc.arg(types)::text[]) AND status IN ('queued', 'running') -GROUP BY type, status -ORDER BY type, status; - --- name: LockCurrentJob :one -SELECT id FROM jobs -WHERE id = sqlc.arg(id) AND status = 'running' - AND lease_owner = sqlc.arg(lease_owner) - AND fencing_token = sqlc.arg(fencing_token) - AND lease_until > now() -FOR UPDATE; - --- name: ClaimJob :one -WITH candidate AS ( - SELECT id FROM jobs - WHERE type = ANY(sqlc.arg(types)::text[]) - AND attempt < max_attempts - AND ((status = 'queued' AND run_after <= now()) - OR (status = 'running' AND lease_until < now())) - ORDER BY run_after, created_at - FOR UPDATE SKIP LOCKED - LIMIT 1 -) -UPDATE jobs AS job -SET status = 'running', lease_owner = sqlc.arg(lease_owner), - lease_until = sqlc.arg(lease_until), fencing_token = fencing_token + 1, - attempt = attempt + 1, started_at = COALESCE(started_at, now()), updated_at = now() -FROM candidate -WHERE job.id = candidate.id -RETURNING job.*; - --- name: RenewJobLease :execrows -UPDATE jobs SET lease_until = sqlc.arg(lease_until), updated_at = now() -WHERE id = sqlc.arg(id) AND status = 'running' - AND lease_owner = sqlc.arg(lease_owner) AND fencing_token = sqlc.arg(fencing_token); - --- name: CompleteJob :execrows -UPDATE jobs SET status = 'succeeded', lease_owner = NULL, lease_until = NULL, - finished_at = now(), updated_at = now() -WHERE id = sqlc.arg(id) AND status = 'running' - AND lease_owner = sqlc.arg(lease_owner) AND fencing_token = sqlc.arg(fencing_token); - --- name: RetryJob :execrows -UPDATE jobs SET status = 'queued', lease_owner = NULL, lease_until = NULL, - run_after = sqlc.arg(run_after), last_error_code = sqlc.arg(error_code), - last_error_message = sqlc.arg(error_message), updated_at = now() -WHERE id = sqlc.arg(id) AND status = 'running' AND attempt < max_attempts - AND lease_owner = sqlc.arg(lease_owner) AND fencing_token = sqlc.arg(fencing_token); - --- name: FailJob :execrows -UPDATE jobs SET status = 'failed', lease_owner = NULL, lease_until = NULL, - last_error_code = sqlc.arg(error_code), last_error_message = sqlc.arg(error_message), - finished_at = now(), updated_at = now() -WHERE id = sqlc.arg(id) AND status = 'running' - AND lease_owner = sqlc.arg(lease_owner) AND fencing_token = sqlc.arg(fencing_token); - --- name: AppendOutboxEvent :exec -INSERT INTO outbox_events ( - id, workspace_id, aggregate, aggregate_id, event_type, payload, trace_context -) VALUES ( - sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(aggregate), sqlc.arg(aggregate_id), - sqlc.arg(event_type), sqlc.arg(payload), sqlc.arg(trace_context) -); - --- name: AppendAuditLog :exec -INSERT INTO audit_logs ( - id, workspace_id, actor_user_id, action, resource_type, resource_id, result, metadata -) VALUES ( - sqlc.arg(id), sqlc.arg(workspace_id), sqlc.narg(actor_user_id), sqlc.arg(action), - sqlc.arg(resource_type), sqlc.narg(resource_id), sqlc.arg(result), sqlc.arg(metadata) -); - --- name: ListAuditLogs :many -SELECT * FROM audit_logs -WHERE workspace_id = sqlc.arg(workspace_id) - AND (sqlc.narg(before_time)::timestamptz IS NULL OR (occurred_at, id) < (sqlc.narg(before_time), sqlc.narg(before_id)::uuid)) -ORDER BY occurred_at DESC, id DESC -LIMIT sqlc.arg(page_size); diff --git a/sql/queries/notifications.sql b/sql/queries/notifications.sql new file mode 100644 index 0000000..dfb0c03 --- /dev/null +++ b/sql/queries/notifications.sql @@ -0,0 +1,44 @@ +-- name: CreateNotification :one +INSERT INTO notifications (id, workspace_id, analysis_run_id, channel_id, event_type, status) +VALUES ($1,$2,$3,$4,$5,'queued') RETURNING *; + +-- name: StartNotification :one +UPDATE notifications SET status='running', started_at=now() +WHERE id=$1 AND status='queued' RETURNING *; + +-- name: FinishNotification :execrows +WITH delivered AS ( + UPDATE notifications SET status='delivered', finished_at=now() + WHERE notifications.id=$1 AND notifications.status='running' RETURNING channel_id +) +UPDATE channels SET last_connected_at=now(), last_error_message=NULL, updated_at=now() +FROM delivered WHERE channels.id=delivered.channel_id; + +-- name: FailNotification :exec +WITH failed AS ( + UPDATE notifications SET status='failed', error_message=$2, finished_at=now() + WHERE notifications.id=$1 AND notifications.status IN ('queued','running') RETURNING channel_id +) +UPDATE channels SET last_error_message=$2, updated_at=now() +FROM failed WHERE channels.id=failed.channel_id; + +-- name: CancelNotification :exec +UPDATE notifications SET status='cancelled', finished_at=now() +WHERE id=$1 AND status IN ('queued','running'); + +-- name: CancelChannelNotifications :exec +WITH cancelled_notifications AS ( + UPDATE notifications + SET status='cancelled', finished_at=now() + WHERE channel_id=$1 AND workspace_id=$2 AND status IN ('queued','running') + RETURNING id +) +UPDATE workflow_dispatches AS dispatch +SET status='cancelled' +FROM cancelled_notifications AS notification +WHERE dispatch.aggregate_type='notification' + AND dispatch.aggregate_id=notification.id + AND dispatch.status='pending'; + +-- name: SetNotificationWorkflowID :exec +UPDATE notifications SET workflow_run_id=$2 WHERE id=$1; diff --git a/sql/queries/outbox.sql b/sql/queries/outbox.sql deleted file mode 100644 index 7c4d7f6..0000000 --- a/sql/queries/outbox.sql +++ /dev/null @@ -1,33 +0,0 @@ --- name: ClaimOutboxEvent :one -WITH candidate AS ( - SELECT id - FROM outbox_events - WHERE published_at IS NULL AND next_attempt_at <= now() - AND (lease_until IS NULL OR lease_until < now()) - ORDER BY next_attempt_at, created_at - FOR UPDATE SKIP LOCKED - LIMIT 1 -) -UPDATE outbox_events AS event -SET lease_owner = sqlc.arg(lease_owner), lease_until = sqlc.arg(lease_until), - attempt = attempt + 1 -FROM candidate -WHERE event.id = candidate.id -RETURNING event.*; - --- name: CompleteOutboxEvent :execrows -UPDATE outbox_events -SET published_at = now(), lease_owner = NULL, lease_until = NULL, - last_error_code = '', last_error_message = '' -WHERE id = sqlc.arg(id) AND published_at IS NULL - AND lease_owner = sqlc.arg(lease_owner) AND attempt = sqlc.arg(attempt); - --- name: RetryOutboxEvent :execrows -UPDATE outbox_events -SET lease_owner = NULL, lease_until = NULL, next_attempt_at = sqlc.arg(next_attempt_at), - last_error_code = sqlc.arg(error_code), last_error_message = sqlc.arg(error_message) -WHERE id = sqlc.arg(id) AND published_at IS NULL - AND lease_owner = sqlc.arg(lease_owner) AND attempt = sqlc.arg(attempt); - --- name: CountOutboxBacklog :one -SELECT count(*) FROM outbox_events WHERE published_at IS NULL; diff --git a/sql/queries/providers.sql b/sql/queries/providers.sql new file mode 100644 index 0000000..708fc44 --- /dev/null +++ b/sql/queries/providers.sql @@ -0,0 +1,54 @@ +-- name: ListProviderConnections :many +SELECT id, user_id, provider_type, base_url, provider_account_id, login, display_name, + scopes, is_default, status, credential_version, last_validated_at, last_used_at, last_error_code, + created_at, updated_at +FROM provider_connections WHERE user_id = $1 AND status <> 'revoked' + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) +ORDER BY created_at DESC, id DESC +LIMIT sqlc.arg('limit'); + +-- name: CreateProviderConnection :one +INSERT INTO provider_connections ( + id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, + provider_account_id, login, display_name, scopes, is_default, status, last_validated_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active',now()) +RETURNING *; + +-- name: GetProviderConnection :one +SELECT * FROM provider_connections WHERE id = $1 AND user_id = $2 AND status <> 'revoked'; + +-- name: MarkProviderConnectionUsed :execrows +UPDATE provider_connections SET last_used_at=now(), updated_at=now() +WHERE id=$1 AND user_id=$2 AND status='active'; + +-- name: ReplaceProviderCredential :one +UPDATE provider_connections +SET token_ciphertext=$3, token_nonce=$4, key_version=$5, + credential_version=credential_version+1, status='active', last_error_code=NULL, updated_at=now() +WHERE id=$1 AND user_id=$2 AND status <> 'revoked' +RETURNING *; + +-- name: SetProviderConnectionValidation :one +UPDATE provider_connections +SET provider_account_id=$3, login=$4, display_name=$5, scopes=$6, status=$7, + last_validated_at=now(), last_error_code=$8, updated_at=now() +WHERE id=$1 AND user_id=$2 AND status <> 'revoked' +RETURNING *; + +-- name: ClearDefaultProviderConnections :exec +UPDATE provider_connections SET is_default=false, updated_at=now() +WHERE user_id=$1 AND provider_type=$2 AND status <> 'revoked'; + +-- name: SetDefaultProviderConnection :one +UPDATE provider_connections SET is_default=true, updated_at=now() +WHERE id=$1 AND user_id=$2 AND status='active' +RETURNING *; + +-- name: RevokeProviderConnection :execrows +UPDATE provider_connections +SET status='revoked', is_default=false, token_ciphertext=decode('', 'hex'), + token_nonce=decode('', 'hex'), credential_version=credential_version+1, updated_at=now() +WHERE id=$1 AND user_id=$2 AND status <> 'revoked'; diff --git a/sql/queries/repositories.sql b/sql/queries/repositories.sql index 4f0d7c8..7d48c9b 100644 --- a/sql/queries/repositories.sql +++ b/sql/queries/repositories.sql @@ -1,102 +1,196 @@ --- name: CreateSCMConnection :one -INSERT INTO scm_connections (id, workspace_id, type, name, base_url, auth_type, secret_ref) -VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(type), sqlc.arg(name), - sqlc.arg(base_url), sqlc.arg(auth_type), sqlc.narg(secret_ref)) +-- name: CreateRepository :one +INSERT INTO repositories (id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, git_path, status, created_by) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'provisioning',$9) RETURNING *; + +-- name: ReserveRepositorySource :one +INSERT INTO repository_source_keys (workspace_id, normalized_url, repository_id) +VALUES ($1,$2,$3) +ON CONFLICT (workspace_id, normalized_url) DO UPDATE +SET normalized_url=excluded.normalized_url +WHERE repository_source_keys.repository_id=excluded.repository_id +RETURNING repository_id; + +-- name: ReleaseRepositorySource :execrows +DELETE FROM repository_source_keys +WHERE workspace_id=$1 AND normalized_url=$2 AND repository_id=$3; + +-- name: GetRepository :one +SELECT * FROM repositories WHERE id=$1 AND workspace_id=$2 AND status <> 'deleted'; + +-- name: ListRepositories :many +SELECT r.*, s.commit_sha AS current_commit_sha, s.author_name AS current_author_name, + s.source_ref AS current_source_ref, s.authored_at AS current_authored_at, s.title AS current_title, + s.source_state AS current_source_state, s.created_at AS snapshot_created_at +FROM repositories r LEFT JOIN commit_snapshots s ON s.id=r.current_snapshot_id +WHERE r.workspace_id=sqlc.arg('workspace_id') + AND r.status <> 'deleted' + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (r.created_at, r.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) +ORDER BY r.created_at DESC, r.id DESC +LIMIT sqlc.arg('limit'); + +-- name: UpdateRepository :one +UPDATE repositories SET name=$3, updated_at=now() +WHERE id=$1 AND workspace_id=$2 RETURNING *; + +-- name: StageRepositoryUpdate :one +UPDATE repositories +SET name=sqlc.arg('name'), provider_type=sqlc.arg('provider_type'), + remote_url=sqlc.arg('remote_url'), normalized_url=sqlc.arg('normalized_url'), + configured_ref=sqlc.arg('configured_ref'), config_version=config_version+1, status='syncing', + updated_at=now() +WHERE id=sqlc.arg('id') AND workspace_id=sqlc.arg('workspace_id') + AND config_version=sqlc.arg('config_version') + AND status NOT IN ('provisioning','syncing','deleting','deleted') RETURNING *; --- name: ListSCMConnections :many -SELECT * FROM scm_connections -WHERE workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL -ORDER BY created_at DESC, id DESC; +-- name: ArchiveRepository :one +UPDATE repositories SET archived_at=now(), updated_at=now() +WHERE id=$1 AND workspace_id=$2 AND archived_at IS NULL RETURNING *; + +-- name: RestoreRepository :one +UPDATE repositories SET archived_at=NULL, updated_at=now() +WHERE id=$1 AND workspace_id=$2 RETURNING *; --- name: GetSCMConnection :one -SELECT * FROM scm_connections -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL; +-- name: DeleteRepository :execrows +DELETE FROM repositories WHERE id=$1 AND workspace_id=$2; --- name: UpdateSCMConnection :one -UPDATE scm_connections -SET name = sqlc.arg(name), base_url = sqlc.arg(base_url), auth_type = sqlc.arg(auth_type), - secret_ref = sqlc.narg(secret_ref), updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL +-- name: RequestRepositoryDeletion :one +UPDATE repositories SET status='deleting', updated_at=now() +WHERE id=$1 AND workspace_id=$2 + AND status NOT IN ('provisioning','syncing','deleting','deleted') RETURNING *; --- name: SoftDeleteSCMConnection :one -UPDATE scm_connections AS connection -SET secret_ref = NULL, deleted_at = now(), updated_at = now() -WHERE connection.id = sqlc.arg(id) AND connection.workspace_id = sqlc.arg(workspace_id) - AND connection.deleted_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM repositories AS repository - WHERE repository.connection_id = connection.id AND repository.deleted_at IS NULL +-- name: SetRepositoryStatus :exec +UPDATE repositories SET status=$2, updated_at=now() WHERE id=$1; + +-- name: SetRepositorySyncing :execrows +UPDATE repositories +SET status='syncing', updated_at=now() +WHERE id=$1 AND config_version=$2 AND status NOT IN ('deleting','deleted'); + +-- name: ProjectRepositoryOperationFailure :execrows +UPDATE repositories +SET status=CASE WHEN current_snapshot_id IS NULL THEN 'failed' ELSE 'ready' END, + provider_type=sqlc.arg('previous_provider_type'), + remote_url=sqlc.arg('previous_remote_url'), + normalized_url=sqlc.arg('previous_normalized_url'), + configured_ref=sqlc.arg('previous_ref'), + last_error_code=sqlc.arg('last_error_code'), last_error_message=sqlc.arg('last_error_message'), updated_at=now() +WHERE id=sqlc.arg('id') AND config_version=sqlc.arg('config_version') AND status <> 'deleted'; + +-- name: RestoreDeletedRepositoryAfterPurgeFailure :execrows +UPDATE repositories +SET status='deleted', last_error_code=sqlc.arg('last_error_code'), + last_error_message=sqlc.arg('last_error_message'), updated_at=now() +WHERE id=sqlc.arg('id') AND config_version=sqlc.arg('config_version') + AND deleted_at IS NOT NULL AND status='deleting'; + +-- name: RestoreRepositoryAfterOperation :execrows +UPDATE repositories +SET status=CASE WHEN current_snapshot_id IS NULL THEN 'failed' ELSE 'ready' END, + provider_type=sqlc.arg('previous_provider_type'), + remote_url=sqlc.arg('previous_remote_url'), + normalized_url=sqlc.arg('previous_normalized_url'), + configured_ref=sqlc.arg('previous_ref'), + updated_at=now() +WHERE id=sqlc.arg('id') AND config_version=sqlc.arg('config_version') AND status IN ('syncing','deleting'); + +-- name: CreateRepositoryOperation :one +INSERT INTO repository_operations ( + id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, + requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, + previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'queued') RETURNING *; + +-- name: GetRepositoryOperation :one +SELECT * FROM repository_operations WHERE id=$1; + +-- name: ListRepositoryOperations :many +SELECT * FROM repository_operations +WHERE repository_id=sqlc.arg('repository_id') + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) ) -RETURNING connection.*; +ORDER BY created_at DESC, id DESC +LIMIT sqlc.arg('limit'); --- name: CreateRepository :one -INSERT INTO repositories ( - id, workspace_id, connection_id, name, clone_url, normalized_url, - ref, state -) VALUES ( - sqlc.arg(id), sqlc.arg(workspace_id), sqlc.narg(connection_id), sqlc.arg(name), - sqlc.arg(clone_url), sqlc.arg(normalized_url), sqlc.arg(ref), 'pending' -) +-- name: StartRepositoryOperation :one +UPDATE repository_operations SET status='running', started_at=now() +WHERE id=$1 AND status='queued' RETURNING *; + +-- name: FinishRepositoryOperation :one +UPDATE repository_operations +SET status='succeeded', outcome=$2, resolved_commit_sha=$3, snapshot_id=$4, finished_at=now() +WHERE id=$1 AND status='running' RETURNING *; + +-- name: FailRepositoryOperation :one +UPDATE repository_operations SET status='failed', error_message=$2, finished_at=now() +WHERE id=$1 AND status IN ('queued','running') RETURNING *; + +-- name: CancelRepositoryOperation :one +UPDATE repository_operations SET status='cancelled', finished_at=now() +WHERE id=$1 AND repository_id=$2 AND status IN ('queued','running') RETURNING *; --- name: ListRepositories :many -SELECT * FROM repositories -WHERE workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL -ORDER BY created_at DESC, id DESC; +-- name: EnsureSnapshot :one +INSERT INTO commit_snapshots (id, repository_id, commit_sha, source_ref, git_ref, author_name, authored_at, title, source_state) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'available') +ON CONFLICT (repository_id, commit_sha) DO UPDATE +SET id=commit_snapshots.id +RETURNING *; --- name: GetRepository :one -SELECT * FROM repositories -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL; - --- name: SetRepositoryRef :execrows -UPDATE repositories SET ref = sqlc.arg(ref), updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL; - --- name: CancelRepositorySyncJobs :exec -UPDATE jobs -SET status = 'cancelled', lease_owner = NULL, lease_until = NULL, - last_error_code = 'repository.deleted', last_error_message = 'Repository was deleted', - finished_at = now(), updated_at = now() -WHERE workspace_id = sqlc.arg(workspace_id) AND type = 'repository.sync' - AND payload->>'repositoryId' = sqlc.arg(repository_id)::text - AND status IN ('queued', 'running'); - --- name: SoftDeleteRepository :one +-- name: SetRepositoryCurrentSnapshot :one UPDATE repositories -SET state = 'deleting', deleted_at = now(), updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL -RETURNING *; +SET current_snapshot_id=sqlc.arg('current_snapshot_id'), + provider_type=sqlc.arg('provider_type'), remote_url=sqlc.arg('remote_url'), + normalized_url=sqlc.arg('normalized_url'), configured_ref=sqlc.arg('configured_ref'), + status='ready', mirror_size_bytes=sqlc.arg('mirror_size_bytes'), + last_sync_at=now(), last_error_code=NULL, last_error_message=NULL, updated_at=now() +WHERE id=sqlc.arg('id') AND workspace_id=sqlc.arg('workspace_id') + AND config_version=sqlc.arg('config_version') RETURNING *; --- name: MarkRepositorySyncing :execrows -UPDATE repositories SET state = 'syncing', last_error_code = '', last_error_message = '', updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL; - --- name: MarkRepositoryFailed :execrows -UPDATE repositories SET state = 'failed', last_error_code = sqlc.arg(error_code), - last_error_message = sqlc.arg(error_message), updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL; - --- name: MarkRepositoryReady :execrows -UPDATE repositories SET state = 'ready', current_commit_sha = sqlc.arg(commit_sha), synced_at = now(), - last_error_code = '', last_error_message = '', updated_at = now() -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL; - --- name: ReserveRepositorySyncCooldown :one -INSERT INTO repository_sync_controls (repository_id, workspace_id, next_allowed_at) -VALUES (sqlc.arg(repository_id), sqlc.arg(workspace_id), sqlc.arg(next_allowed_at)) -ON CONFLICT (repository_id) DO UPDATE -SET next_allowed_at = EXCLUDED.next_allowed_at, updated_at = now() -WHERE repository_sync_controls.workspace_id = EXCLUDED.workspace_id - AND repository_sync_controls.next_allowed_at <= now() -RETURNING repository_id; +-- name: GetSnapshot :one +SELECT * FROM commit_snapshots WHERE id=$1 AND repository_id=$2; + +-- name: GetWorkspaceSnapshot :one +SELECT s.* FROM commit_snapshots s +JOIN repositories r ON r.id=s.repository_id +WHERE s.id=$1 AND s.repository_id=$2 AND r.workspace_id=$3; + +-- name: ListRepositorySnapshots :many +SELECT s.* FROM commit_snapshots s +JOIN repositories r ON r.id=s.repository_id +WHERE s.repository_id=$1 AND r.workspace_id=$2 + AND ( + sqlc.narg('cursor_time')::timestamptz IS NULL + OR (s.created_at, s.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid) + ) +ORDER BY s.created_at DESC, s.id DESC +LIMIT sqlc.arg('limit'); --- name: GetWorkspaceOverview :one -SELECT - (SELECT count(*) FROM repositories AS repository - WHERE repository.workspace_id = sqlc.arg(target_workspace_id) AND repository.deleted_at IS NULL)::bigint AS repository_count, - (SELECT count(*) FROM channel_instances AS channel - WHERE channel.workspace_id = sqlc.arg(target_workspace_id) AND channel.enabled = true AND channel.deleted_at IS NULL)::bigint AS active_channel_count, - (SELECT count(*) FROM jobs AS job - WHERE job.workspace_id = sqlc.arg(target_workspace_id) AND job.status = 'failed')::bigint AS failed_job_count; +-- name: MarkSnapshotsPurged :exec +UPDATE commit_snapshots SET source_state='purged' WHERE repository_id=$1; + +-- name: SetRepositoryOperationWorkflowID :exec +UPDATE repository_operations SET workflow_run_id=$2 WHERE id=$1; + +-- name: HasRepositoryAnalysisRuns :one +SELECT EXISTS (SELECT 1 FROM analysis_runs WHERE repository_id=$1); + +-- name: CompleteRepositoryLogicalDeletion :execrows +WITH completed AS ( + UPDATE repository_operations + SET status='succeeded', outcome='no_change', finished_at=now() + WHERE repository_operations.id=$1 AND repository_operations.repository_id=$2 AND repository_operations.status='running' + RETURNING repository_operations.repository_id +) +UPDATE repositories +SET status='deleted', archived_at=COALESCE(archived_at, now()), deleted_at=now(), updated_at=now() +FROM completed +WHERE repositories.id=completed.repository_id; +-- name: CountManagedRepositories :one +SELECT count(*) FROM repositories WHERE workspace_id=$1 AND status <> 'deleted'; diff --git a/sql/queries/retention.sql b/sql/queries/retention.sql new file mode 100644 index 0000000..dd096ab --- /dev/null +++ b/sql/queries/retention.sql @@ -0,0 +1,154 @@ +-- name: ClaimDueRetentionSchedules :many +SELECT schedule.workspace_id, schedule.next_run_at, workspace.report_retention_days +FROM retention_cleanup_schedules AS schedule +JOIN workspaces AS workspace ON workspace.id = schedule.workspace_id +WHERE schedule.next_run_at <= clock_timestamp() +ORDER BY schedule.next_run_at, schedule.workspace_id +FOR UPDATE OF schedule SKIP LOCKED +LIMIT sqlc.arg('limit'); + +-- name: AdvanceRetentionSchedule :execrows +UPDATE retention_cleanup_schedules +SET last_run_at = clock_timestamp(), + next_run_at = GREATEST(next_run_at + INTERVAL '1 day', clock_timestamp() + INTERVAL '1 day'), + updated_at = clock_timestamp() +WHERE workspace_id = $1 AND next_run_at = $2; + +-- name: CreateRetentionCleanup :one +INSERT INTO retention_cleanups (id, workspace_id, scheduled_for, retention_days, status) +VALUES ($1, $2, $3, $4, 'queued') +RETURNING *; + +-- name: GetRetentionCleanup :one +SELECT * FROM retention_cleanups WHERE id = $1; + +-- name: StartRetentionCleanup :one +UPDATE retention_cleanups +SET status = 'running', started_at = COALESCE(started_at, clock_timestamp()), error_message = NULL +WHERE id = $1 AND status IN ('queued', 'running') +RETURNING *; + +-- name: DeleteExpiredAnalysisRuns :many +WITH candidates AS ( + SELECT analysis_run.id + FROM analysis_runs AS analysis_run + WHERE analysis_run.workspace_id = sqlc.arg('workspace_id') + AND analysis_run.status IN ('succeeded', 'failed', 'cancelled') + AND analysis_run.finished_at < clock_timestamp() - make_interval(days => sqlc.arg('retention_days')::integer) + ORDER BY analysis_run.finished_at, analysis_run.id + FOR UPDATE SKIP LOCKED + LIMIT sqlc.arg('limit') +) +DELETE FROM analysis_runs AS run +USING candidates +WHERE run.id = candidates.id +RETURNING run.snapshot_id; + +-- name: LockPurgeableSnapshotCandidates :many +SELECT snapshot.id +FROM commit_snapshots AS snapshot +JOIN repositories AS repository ON repository.id = snapshot.repository_id +WHERE repository.workspace_id = sqlc.arg('workspace_id') + AND ( + snapshot.source_state = 'available' + OR ( + snapshot.source_state = 'purging' + AND ( + snapshot.purge_cleanup_id = sqlc.arg('cleanup_id') + OR EXISTS ( + SELECT 1 + FROM retention_cleanups AS previous_cleanup + WHERE previous_cleanup.id = snapshot.purge_cleanup_id + AND previous_cleanup.status = 'failed' + ) + ) + ) + ) + AND repository.current_snapshot_id IS DISTINCT FROM snapshot.id + AND NOT EXISTS ( + SELECT 1 FROM analysis_runs AS run WHERE run.snapshot_id = snapshot.id + ) +ORDER BY snapshot.created_at, snapshot.id +FOR UPDATE OF snapshot SKIP LOCKED +LIMIT sqlc.arg('limit'); + +-- name: MarkSnapshotsPurging :many +UPDATE commit_snapshots AS snapshot +SET source_state = 'purging', purge_cleanup_id = sqlc.arg('cleanup_id') +FROM repositories AS repository +WHERE snapshot.id = ANY(sqlc.arg('snapshot_ids')::uuid[]) + AND repository.id = snapshot.repository_id + AND repository.workspace_id = sqlc.arg('workspace_id') + AND repository.current_snapshot_id IS DISTINCT FROM snapshot.id + AND NOT EXISTS ( + SELECT 1 FROM analysis_runs AS run WHERE run.snapshot_id = snapshot.id + ) + AND ( + snapshot.source_state = 'available' + OR ( + snapshot.source_state = 'purging' + AND ( + snapshot.purge_cleanup_id = sqlc.arg('cleanup_id') + OR EXISTS ( + SELECT 1 + FROM retention_cleanups AS previous_cleanup + WHERE previous_cleanup.id = snapshot.purge_cleanup_id + AND previous_cleanup.status = 'failed' + ) + ) + ) + ) +RETURNING snapshot.id, snapshot.repository_id, snapshot.git_ref, snapshot.commit_sha; + +-- name: CompleteSnapshotPurge :execrows +UPDATE commit_snapshots +SET source_state = 'purged', purge_cleanup_id = NULL +WHERE id = $1 AND repository_id = $2 AND purge_cleanup_id = $3 AND source_state = 'purging'; + +-- name: AddRetentionCleanupProgress :execrows +UPDATE retention_cleanups +SET deleted_run_count = deleted_run_count + sqlc.arg('deleted_runs')::integer, + purged_snapshot_count = purged_snapshot_count + sqlc.arg('purged_snapshots')::integer +WHERE id = sqlc.arg('id') AND status = 'running'; + +-- name: ClaimRepositoriesReadyForPurge :many +SELECT repository.* +FROM repositories AS repository +WHERE repository.workspace_id = sqlc.arg('workspace_id') + AND repository.status = 'deleted' + AND NOT EXISTS ( + SELECT 1 FROM analysis_runs AS run WHERE run.repository_id = repository.id + ) + AND NOT EXISTS ( + SELECT 1 FROM repository_operations AS operation + WHERE operation.repository_id = repository.id + AND operation.kind = 'purge' + AND operation.status IN ('queued', 'running') + ) +ORDER BY repository.deleted_at, repository.id +FOR UPDATE OF repository SKIP LOCKED +LIMIT sqlc.arg('limit'); + +-- name: ReactivateRepositoryPurge :execrows +UPDATE repositories +SET status = 'deleting', updated_at = clock_timestamp() +WHERE id = $1 AND status = 'deleted'; + +-- name: AddRequeuedRepositoryCount :execrows +UPDATE retention_cleanups +SET requeued_repository_count = requeued_repository_count + $2 +WHERE id = $1 AND status = 'running'; + +-- name: FinishRetentionCleanup :one +UPDATE retention_cleanups +SET status = 'succeeded', finished_at = clock_timestamp(), error_message = NULL +WHERE id = $1 AND status = 'running' +RETURNING *; + +-- name: FailRetentionCleanup :execrows +UPDATE retention_cleanups +SET status = 'failed', error_message = $2, finished_at = clock_timestamp() +WHERE id = $1 AND status IN ('queued', 'running'); + +-- name: SetRetentionCleanupWorkflowID :exec +UPDATE retention_cleanups SET workflow_run_id = $2 WHERE id = $1; diff --git a/sql/queries/secrets.sql b/sql/queries/secrets.sql deleted file mode 100644 index 8ec961e..0000000 --- a/sql/queries/secrets.sql +++ /dev/null @@ -1,18 +0,0 @@ --- name: CreateSecret :one -INSERT INTO secrets ( - id, workspace_id, resource_type, resource_id, ciphertext, nonce, - wrapped_key, wrapped_key_nonce, key_version -) VALUES ( - sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(resource_type), sqlc.arg(resource_id), - sqlc.arg(ciphertext), sqlc.arg(nonce), sqlc.arg(wrapped_key), - sqlc.arg(wrapped_key_nonce), sqlc.arg(key_version) -) -RETURNING *; - --- name: GetSecret :one -SELECT * FROM secrets -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id); - --- name: DeleteSecret :exec -DELETE FROM secrets -WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id); diff --git a/sql/queries/workflows.sql b/sql/queries/workflows.sql new file mode 100644 index 0000000..286d0a4 --- /dev/null +++ b/sql/queries/workflows.sql @@ -0,0 +1,34 @@ +-- name: CreateWorkflowDispatch :one +INSERT INTO workflow_dispatches (id, aggregate_type, aggregate_id, workflow_name, payload, status) +VALUES ($1,$2,$3,$4,$5,'pending') RETURNING *; + +-- name: ClaimWorkflowDispatches :many +WITH candidates AS ( + SELECT id FROM workflow_dispatches + WHERE status='pending' AND available_at <= now() + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT sqlc.arg('limit') +) +UPDATE workflow_dispatches AS dispatch +SET attempts=dispatch.attempts+1, + available_at=now() + sqlc.arg('lease_duration')::interval +FROM candidates +WHERE dispatch.id=candidates.id +RETURNING dispatch.*; + +-- name: MarkWorkflowDispatched :execrows +UPDATE workflow_dispatches SET status='dispatched', dispatched_at=now() +WHERE id=$1 AND status='pending'; + +-- name: DelayWorkflowDispatch :exec +UPDATE workflow_dispatches +SET available_at=$2, last_error=$3 +WHERE id=$1 AND status='pending'; + +-- name: IsWorkflowDispatchPending :one +SELECT EXISTS(SELECT 1 FROM workflow_dispatches WHERE id=$1 AND status='pending'); + +-- name: CancelWorkflowDispatch :exec +UPDATE workflow_dispatches SET status='cancelled' +WHERE aggregate_type=$1 AND aggregate_id=$2 AND status='pending'; diff --git a/sqlc.yaml b/sqlc.yaml index fe5550d..df310ec 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,14 +1,23 @@ version: "2" sql: - engine: postgresql - schema: migrations/postgres + schema: migrations/postgres/000001_initial.up.sql queries: sql/queries gen: go: - package: db - out: internal/repository/postgres/sqlc + package: sqlc + out: internal/data/sqlc sql_package: pgx/v5 emit_interface: true emit_json_tags: true emit_empty_slices: true - emit_pointers_for_null_types: true + overrides: + - db_type: uuid + go_type: + import: github.com/google/uuid + type: UUID + - db_type: uuid + nullable: true + go_type: + import: github.com/google/uuid + type: NullUUID