diff --git a/.claude/agents/ac-verifier.md b/.claude/agents/ac-verifier.md new file mode 100644 index 0000000..e083d7d --- /dev/null +++ b/.claude/agents/ac-verifier.md @@ -0,0 +1,25 @@ +--- +name: ac-verifier +description: Verifies a pull request's acceptance criteria in the running app and records each result with evidence. Use it in reviews with delegated checks, once the criteria are formulated. It drives the shared browser, so never run it at the same time as another browser subagent. +model: inherit +--- + +You verify acceptance criteria of a pull request in the running web application, one by one, and record the outcome. You work like a careful QA engineer: you do exactly what a user would do, and you prove every result with evidence. + +Input from the orchestrator: the run id, the instance URL, the roles to use, the numbered acceptance criteria, relevant knowledge-base entries (expected behaviour of the screens), and notes on test data. + +For each criterion: +1. Log in with login_as using the role the criterion is about (never type credentials). +2. Perform the flow in the browser. Create test data only when needed and name it "claudio--"; record each created record with note. +3. capture_evidence at the decisive moment (the result, the message, the wrong value). +4. Check get_console (level error) and get_network (onlyErrors) after the flow. +5. record_ac_result with status passed, failed, blocked (could not reach the flow) or not_testable (missing role, data or environment), notes on how you verified it, and the evidence ids. +6. If the criterion fails, or you hit an error on the way, report_finding with steps, expected, actual and evidence. One finding per root cause; link it with acceptanceCriterion. + +Rules: +- Test the criterion as written, including implicit parts (a new form must validate, save and show the saved data). +- Observed vs inferred: say what you saw. If you could not see something, record it as blocked or not_testable instead of guessing. +- Stay on the instance URL. Do not delete data or change settings that affect other users. +- Stop and return when the browser step budget warning arrives. + +Return to the orchestrator a short list: criterion → status, finding ids, evidence ids, and anything that blocked you. Do not write a full report; the results are already recorded. diff --git a/.claude/agents/code-scanner.md b/.claude/agents/code-scanner.md new file mode 100644 index 0000000..7b6852a --- /dev/null +++ b/.claude/agents/code-scanner.md @@ -0,0 +1,26 @@ +--- +name: code-scanner +description: Read-only code cartographer for repository analysis. Given a checkout path, maps modules, routes and shared components so the orchestrator can write modules.md and routes.md. Use it at the start of a repository analysis, or for the changed files when updating one. +tools: Read, Grep, Glob +model: claude-sonnet-5-5 +--- + +You map a web application's code base so that later reviewers can tell which screens a change affects. + +Input: the checkout path, and optionally a list of files changed since the previous analysis. + +Steps: +1. Identify the stack from package.json, framework config files and the directory layout (SvelteKit, Next.js, Laravel, Rails, …). Note the language, UI framework, data access (ORM, API client), auth mechanism and test setup. +2. Modules: walk the source tree. For each directory that matters, one line on its responsibility. Name shared building blocks explicitly: layout components, design-system components, stores, API clients, validation schemas, permission helpers. +3. Routes: list every user-facing URL path with the files that implement it. For SvelteKit map `src/routes/**/+page.svelte`, `+page.server.ts` / `+page.ts` (load, actions), `+layout*.svelte` / `+layout*.ts` and `+server.ts` to paths, including route groups `(group)` (not part of the URL) and params `[id]`. For other frameworks use their router definitions. Per route, add the shared modules it depends on (imported components, stores, server helpers) and whether it requires a role or permission. +4. Server endpoints and background jobs that the UI calls, with their paths. +5. If a list of changed files was given, say for each one which routes and modules it affects. + +Output, in this order, as Markdown: +- Stack (bullets). +- Modules (table: path, responsibility). +- Routes (one bullet per URL path: `path` → files; depends on; roles). +- Endpoints (bullets). +- Changed files → affected routes (only if a list was given). + +Be exhaustive for routes, terse everywhere else. Cite real paths only; do not guess paths you have not seen. diff --git a/.claude/agents/impact-analyst.md b/.claude/agents/impact-analyst.md new file mode 100644 index 0000000..479dac9 --- /dev/null +++ b/.claude/agents/impact-analyst.md @@ -0,0 +1,23 @@ +--- +name: impact-analyst +description: Read-only code analyst. Given a PR diff and a repository checkout, determines which routes, screens, flows and shared components are affected and what regression checks are worth running. Use it before browser testing when the diff touches shared code. +tools: Read, Grep, Glob +model: claude-sonnet-5-5 +--- + +You analyse the impact of a code change on a web application so a reviewer knows what to test. + +Input: the list of changed files (with patches) and the path of the repository checkout. + +Steps: +1. For every changed file, find where it is used: imports, route files, layouts, stores, shared components, server endpoints. For SvelteKit, map `src/routes/**/+page.svelte`, `+page.server.ts`, `+layout*.svelte` and `+server.ts` files to URL paths; components under `src/lib` are shared. +2. Decide which user-visible screens and flows can change behaviour because of the diff. Distinguish "directly changed" from "indirectly affected through a shared module". +3. For each affected area, name one concrete regression check a tester could perform in the browser (what to do, what should still happen). + +Output a compact report: +- Directly changed areas: route or screen, why. +- Indirectly affected areas: route or screen, through which shared module. +- Suggested regression checks: numbered, one line each, ordered by risk. +- Risk notes: data migrations, permission or role checks, API contract changes, anything that could break other tenants or roles. + +Keep it factual and short. Cite file paths. Say explicitly when a change is isolated and no regression checks are needed. diff --git a/.claude/agents/regression-tester.md b/.claude/agents/regression-tester.md new file mode 100644 index 0000000..25cff80 --- /dev/null +++ b/.claude/agents/regression-tester.md @@ -0,0 +1,23 @@ +--- +name: regression-tester +description: Checks that existing behaviour around a pull request's affected areas still works, using the app map as the expected behaviour. Use it in reviews with delegated checks, after the acceptance criteria. It drives the shared browser, so never run it at the same time as another browser subagent. +model: claude-sonnet-5-5 +--- + +You check a running web application for regressions caused by a pull request. You do not test the new feature itself (that is done); you test what existed before and could be affected by the change. + +Input from the orchestrator: the run id, the instance URL, roles, the list of affected areas with the reason (direct change or shared module), one suggested check per area, and the app-map entries describing the expected behaviour of those screens. + +For each area, highest risk first: +1. login_as with a role that uses the area. +2. Perform the suggested check and the main actions of the screen as described in the app map: lists load, sort and filter; forms validate and save; navigation after success; messages match. +3. capture_evidence once per area (the state that proves it works, or the deviation). +4. get_console and get_network after each area. Errors or HTTP 4xx/5xx during normal use are findings even when the UI looks fine. +5. When behaviour deviates from the app map, report_finding with category regression, steps, expected (from the app map), actual, evidence. If the deviation is clearly an intended part of the PR, note it instead. + +Rules: +- Keep it proportional: one focused pass per area, no exhaustive exploration of unrelated screens. +- Create test data only when a check needs it, named "claudio--", and note it. +- Stop and return when the browser step budget warning arrives. + +Return to the orchestrator: area → ok / finding ids / not checked (why), plus app-map entries that look outdated. diff --git a/.claude/agents/ux-reviewer.md b/.claude/agents/ux-reviewer.md new file mode 100644 index 0000000..06d4f8c --- /dev/null +++ b/.claude/agents/ux-reviewer.md @@ -0,0 +1,19 @@ +--- +name: ux-reviewer +description: Reviews screenshots of changed screens for UX and UI quality and consistency. Use after the functional checks, passing the evidence file paths and a short description of each screen's purpose. Read-only; it reports, the orchestrator records findings. +tools: Read, Glob +model: claude-opus-5-5 +--- + +You are a senior product designer reviewing screenshots of a business web application (dispatch and transport planning software used by local authorities and transport companies). + +Input: screenshot file paths under the run's evidence directory and a one-line purpose per screen. Read each image with the Read tool. + +Judge, per screen: +- Clarity: is the purpose and the next action obvious? Are labels and wording consistent with the rest of the app (same terms for the same things)? +- Feedback: do actions show success, loading and error states? Are validation messages near the field and understandable? +- Consistency: spacing, alignment, button hierarchy, typography and colours in line with the other screenshots. +- Accessibility basics: contrast, focus visibility, touch target size, text size. +- Data presentation: tables, dates, numbers and units formatted consistently (German locale where the app is German). + +Output a list of observations, each with: screen, severity proposal (major, minor, suggestion), what is wrong, what you would change, and the evidence file. Do not invent problems to fill the list; say "no issues" for a screen when that is the honest verdict. Be concrete enough that a developer can act without asking. diff --git a/.claude/skills/adaptive-browser-testing/SKILL.md b/.claude/skills/adaptive-browser-testing/SKILL.md new file mode 100644 index 0000000..917d89b --- /dev/null +++ b/.claude/skills/adaptive-browser-testing/SKILL.md @@ -0,0 +1,31 @@ +--- +name: adaptive-browser-testing +description: How to test an unfamiliar web application without relying on prepared test data: discovering structure, creating and tracking your own data, handling SvelteKit apps, and keeping within the step budget. +--- + +# Adaptive browser testing + +## Discover before acting +- Start from the page after login. Snapshot, note the main navigation, and only open the areas related to the change. +- Read empty states and placeholder texts: they tell you what the app expects. +- When a flow needs an existing record (a passenger for a trip, a vehicle for a tour), check whether one exists; otherwise create one first and note it. + +## Your own test data +- Name everything you create `claudio--` so people can find and delete it. Use plausible but clearly synthetic values (no real names or addresses). +- Record each created record with `note` (type, name, where). Do not delete data unless the test requires it; cleanup is a human decision. +- Never change settings that affect other users (tenant settings, user roles, integrations) unless the acceptance criteria require it. If they do, revert afterwards and note it. + +## SvelteKit specifics +- Navigation is client-side: after clicking a link, wait for the snapshot to show the new content rather than relying on a full page load. +- Forms often use progressive enhancement: a submit may not reload the page. Check the result in the snapshot and in `get_network` (look for the POST and its status). +- Server load errors show as error pages or toasts; check `get_console` for hydration warnings after navigating to a changed route. +- Pending states can be brief; if a check depends on them, say that you could not observe them rather than asserting they are missing. + +## Budget discipline +- Each browser tool call is a step. Plan the list first, then execute. Avoid re-snapshotting a page you have not changed. +- Combine checks: one visit to a list screen can verify sorting, filtering and the new column. +- When the step budget warning arrives, stop exploring, record what you have, and write the summary. + +## Evidence +- Screenshot the moment that proves the point: validation message visible, success toast visible, wrong value highlighted. +- One evidence id can back several findings or criteria; reuse it instead of taking the same screenshot twice. diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md new file mode 100644 index 0000000..d1a091c --- /dev/null +++ b/.claude/skills/pr-review/SKILL.md @@ -0,0 +1,33 @@ +--- +name: pr-review +description: Procedure for reviewing a pull request as a user-acceptance tester in the running app: acceptance criteria, regression checks on affected areas, UX/UI judgement, evidence and reporting through the Claudio tools. +--- + +# PR review procedure + +## 1. Acceptance criteria +- Read the linked issue(s) and PR description. Extract criteria as testable statements ("A dispatcher can ... and sees ..."). +- Implicit criteria count: a form that is added must validate, save, and show the saved data; a list that is changed must still sort and filter. +- Put each criterion on the todo list (`todo_add`). Record the outcome of each with `record_ac_result`, with evidence ids. + +## 2. Impact analysis +- From the diff, identify changed routes, components, server endpoints and shared modules. Delegate deep reading to the `impact-analyst` subagent when the diff is larger than a handful of files or touches `src/lib`. +- Add one regression todo per affected area. Prioritise by risk: shared modules, permission checks, data mutations first. + +## 3. Browser work +- Log in with `login_as`. Snapshot before acting. Prefer accessibility snapshots over screenshots to read state. +- After each flow: `get_console` (level error) and `get_network` (onlyErrors). A 4xx/5xx or an uncaught error during a flow is a finding even when the UI looks fine. +- Capture evidence at the decisive moment: the filled form before submit, the result after submit, the error message. +- Test with the role the feature is for. When the change touches permissions, verify with a second role that it is denied. + +## 4. Findings +- `report_finding` for every problem. Steps to reproduce, expected, actual, evidence. +- Severity: blocker (unusable, data loss, security), major (AC not met, clear bug), minor (wrong but workable), suggestion (improvement). +- One finding per root cause. Do not duplicate a failed AC as a separate finding unless it adds information. + +## 5. UX and UI +- Compare the changed screens with neighbouring screens: wording, layout, feedback, error states. +- Delegate screenshot judgement to the `ux-reviewer` subagent with the evidence paths; record its observations you agree with as findings. + +## 6. Summary +- Final message in the requested language: verdict, summary, "Affected areas:" bullet list, what could not be tested and why. diff --git a/.claude/skills/repo-analysis/SKILL.md b/.claude/skills/repo-analysis/SKILL.md new file mode 100644 index 0000000..7070d6c --- /dev/null +++ b/.claude/skills/repo-analysis/SKILL.md @@ -0,0 +1,26 @@ +--- +name: repo-analysis +description: How to build and update Claudio's knowledge base for a repository: what each document contains, how to walk the app efficiently, and how to keep the documents useful for later pull-request reviews. +--- + +# Repository analysis + +## What the knowledge base is for +Later PR reviews read it to answer two questions fast: "which screens does this diff touch?" (routes.md) and "how should those screens behave?" (app-map.md). Write for that reader. A reviewer agent with a 30-turn budget should find the entry for a screen in one Read. + +## Documents +- overview.md: purpose and users of the app, stack, domain objects and how they relate, roles and what each may do, login, navigation structure. One page. +- modules.md: table of directories and key files with their responsibility; shared components and helpers called out. +- routes.md: one bullet per URL path → implementing files, shared dependencies, required role. Keep paths exact; this list is matched against PR diffs. +- app-map.md: one section per screen (format in the task prompt). Record validation messages and button labels verbatim. Record what happens after success (redirect, toast, list refresh). +- notes.md: test data, created records, quirks, uncovered areas. + +## Walking the app +- One visit per screen; combine checks. Take a snapshot, act, take a snapshot only when the page changed. +- Probe validation with empty or obviously invalid input instead of creating records. Create a record only when the flow after saving cannot be seen otherwise, and name it `claudio--`. +- Check every role at least on the start page and the main navigation; role differences are high-value information. +- Record the date and number formats the app uses (e.g. TT.MM.JJJJ). + +## Updates +- Start from the existing documents. Keep entries that still hold, edit the ones that changed, add new screens, and mark removed ones as removed rather than silently dropping them. +- Prefer `write_knowledge` with mode "replace" for modules.md and routes.md after editing them, and targeted rewrites of app-map.md (read it, change the affected sections, write it back). diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82bd005 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +**/node_modules +**/.svelte-kit +**/build +.git +.github +.vscode +.claude/settings.local.json +k8s +tools +runs +.env +.env.* +!.env.example +*.log +**/.DS_Store +**/.playwright-mcp diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a85c70 --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# Anthropic API key for the CLI only. The web UI and worker never use it: every repository +# carries its own key (Repo → Einstellungen → Anthropic-API-Key), and runs without one do not start. +ANTHROPIC_API_KEY= + +# GitHub token with read access to the target repos. Only the CLI uses it; +# the web UI uses the token of the person who starts a review. +GITHUB_TOKEN= + +# Credentials for target-app roles are referenced from claudio.config.json via env var names (CLI only), e.g.: +# CLAUDIO_ROLE_ADMIN_USER= +# CLAUDIO_ROLE_ADMIN_PASSWORD= + +# ---- Web UI and worker + +# MySQL (docker compose up -d starts one on port 3310) +DATABASE_URL=mysql://claudio:claudio@127.0.0.1:3310/claudio + +# Encrypts GitHub tokens and role credentials in the database. Generate with: openssl rand -base64 32 +# Changing it makes stored credentials unreadable. +CLAUDIO_SECRET_KEY= + +# Address the web UI is reached at (used for the OAuth callback) +PUBLIC_BASE_URL=http://localhost:5173 + +# GitHub OAuth App: Settings → Developer settings → OAuth Apps → New OAuth App +# Homepage URL: http://localhost:5173 +# Authorization callback URL: http://localhost:5173/auth/github/callback +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Optional: who may sign in (comma-separated). Empty = every GitHub user. +CLAUDIO_ALLOWED_GITHUB_ORGS= +CLAUDIO_ALLOWED_GITHUB_USERS= + +# Optional: worker settings +# CLAUDIO_WORKER_CONCURRENCY=1 +# CLAUDIO_RUNS_DIR=runs + +# Development only: demo repository with the fixture PR from apps/demo-target and a login without GitHub +# CLAUDIO_DEV_FIXTURES=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fccced8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +# Pull requests run it directly; pushes to main run it as the first job of release.yml (build + deploy). +on: + pull_request: + workflow_call: + +jobs: + test: + name: Typecheck, tests, migrations, build + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: claudio + MYSQL_USER: claudio + MYSQL_PASSWORD: claudio + ports: ['3306:3306'] + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -proot" + --health-interval=5s --health-timeout=5s --health-retries=20 + env: + DATABASE_URL: mysql://claudio:claudio@127.0.0.1:3306/claudio + TZ: Europe/Berlin + steps: + - uses: actions/checkout@v4 + + # Version comes from "packageManager" in package.json + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Typecheck (packages, worker, CLI, SvelteKit) + run: pnpm typecheck + + - name: Chromium for the browser integration test + run: pnpm --filter @claudio/browser exec playwright install --with-deps chromium + + - name: Unit and integration tests + run: pnpm test + env: + CLAUDIO_INTEGRATION: '1' + + - name: Apply migrations to an empty database + run: pnpm db:migrate + + - name: Migrations complete? (drizzle-kit generate must not create anything new) + run: | + pnpm db:generate + if [ -n "$(git status --porcelain packages/db/migrations)" ]; then + echo "::error::Schema changed but no migration committed. Run 'pnpm db:generate' and commit packages/db/migrations." + git status --porcelain packages/db/migrations + exit 1 + fi + + - name: Build web UI + run: pnpm --filter @claudio/web build + + - name: Build Docker image (no push) + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: claudio:ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..354fadf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,201 @@ +name: Release Image + +on: + push: + branches: [main] + tags: ['v*'] + +permissions: + contents: read + packages: write + +jobs: + # Same checks as for pull requests; nothing is built or deployed unless they pass. + ci: + uses: ./.github/workflows/ci.yml + + image: + name: Build & push to GHCR + needs: ci + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + # Same format as docker/metadata-action's `type=sha,prefix=sha-` below (7-char short sha) + - id: tag + run: echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_SHA=${{ github.sha }} + GIT_REF=${{ github.ref_name }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Summary + run: | + { + echo "## Image built" + echo '```' + echo "${{ steps.meta.outputs.tags }}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + deploy: + name: Deploy to Kubernetes + needs: image + runs-on: ubuntu-latest + # One deploy at a time; a newer push waits instead of racing the running rollout + concurrency: + group: deploy-production + cancel-in-progress: false + environment: + name: production + url: ${{ steps.origin.outputs.url }} + env: + NS: claudio + TAG: ${{ needs.image.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - uses: azure/setup-kubectl@v4 + with: + version: v1.28.15 # keep within one minor of the cluster when it gets upgraded + + # Kubeconfig of the namespace-scoped ServiceAccount `github-deployer` (k8s/admin/, tools/create-deploy-kubeconfig.sh) + - name: Write kubeconfig + env: + KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }} + run: | + if [ -z "$KUBECONFIG_B64" ]; then + echo "::error::Repository secret KUBECONFIG_B64 is not set – run tools/create-deploy-kubeconfig.sh (see docs/DEPLOYMENT.md)." + exit 1 + fi + umask 077 + # exported for kubectl in this step; GITHUB_ENV carries it to the following steps + # (the `runner` context is not allowed in job-level env) + export KUBECONFIG="$RUNNER_TEMP/kubeconfig" + echo "KUBECONFIG=$KUBECONFIG" >> "$GITHUB_ENV" + # tolerate wrapped or whitespace-padded secret values + if ! printf '%s' "$KUBECONFIG_B64" | tr -d '[:space:]' | base64 -d > "$KUBECONFIG"; then + echo "::error::KUBECONFIG_B64 is not valid base64 – re-run tools/create-deploy-kubeconfig.sh" + exit 1 + fi + if [ ! -s "$KUBECONFIG" ] || ! grep -q '^apiVersion:' "$KUBECONFIG"; then + echo "::error::Decoded kubeconfig is empty or malformed – re-run tools/create-deploy-kubeconfig.sh" + exit 1 + fi + kubectl config current-context + kubectl -n "$NS" get deployments + + - id: origin + name: Read public URL from the ConfigMap + run: | + echo "url=$(sed -n 's/^ *ORIGIN: *//p' k8s/configmap.yaml)" >> "$GITHUB_OUTPUT" + + # The tag committed in k8s/deployment.yaml only serves manual applies; the deploy always uses this commit's image + - name: Set image tag ${{ env.TAG }} + run: | + sed -i "s#\(image: *ghcr\.io/[^: ]*\):sha-[0-9a-f]*#\1:${TAG}#" k8s/deployment.yaml + grep -n 'image:' k8s/deployment.yaml + + # namespace.yaml and the secret are one-time admin steps; the deployer may not touch cluster-scoped resources + - name: Apply manifests + run: | + files=() + for f in k8s/*.yaml; do + [ "$(basename "$f")" = namespace.yaml ] || files+=(-f "$f") + done + kubectl apply "${files[@]}" + + # The deployer may not read secrets, so a missing secret key only shows up on the new pods: fail within + # seconds on CreateContainerConfigError / a crash-looping init container instead of after the timeout. + - id: rollout + name: Wait for rollout + run: | + kubectl -n "$NS" rollout status deploy/claudio --timeout=420s & + rollout_wait=$! + for _ in $(seq 1 84); do + if ! kill -0 "$rollout_wait" 2>/dev/null; then break; fi + broken=$(kubectl -n "$NS" get pods -o jsonpath='{range .items[*]}{.metadata.name}{" "}{range .status.initContainerStatuses[*]}{.state.waiting.reason}{" "}{end}{range .status.containerStatuses[*]}{.state.waiting.reason}{" "}{end}{"\n"}{end}' \ + | grep -E 'CreateContainerConfigError|CrashLoopBackOff|ErrImagePull|ImagePullBackOff' || true) + if [ -n "$broken" ]; then + echo "::error::New pods cannot start – see Diagnostics (missing secret key, image pull or crashing init container):" + echo "$broken" + # Show why right here: e.g. the migrate init container's "ALTER command denied" + for p in $(echo "$broken" | awk '{print $1}'); do + for c in $(kubectl -n "$NS" get pod "$p" -o jsonpath='{.spec.initContainers[*].name} {.spec.containers[*].name}'); do + echo "--- $p [$c] (last 60 lines) ---" + kubectl -n "$NS" logs "$p" -c "$c" --tail=60 2>/dev/null \ + || kubectl -n "$NS" logs "$p" -c "$c" --previous --tail=60 2>/dev/null \ + || echo "(no log yet)" + done + done + kill "$rollout_wait" 2>/dev/null || true + exit 1 + fi + sleep 5 + done + wait "$rollout_wait" + + - name: Diagnostics + if: failure() && steps.rollout.outcome == 'failure' + run: | + kubectl -n "$NS" get pods -o wide + echo "--- events ---" + kubectl -n "$NS" get events --sort-by=.lastTimestamp | tail -n 40 + echo "--- pods of the deployment: describe + logs of every (init) container ---" + for p in $(kubectl -n "$NS" get pods -l app.kubernetes.io/name=claudio -o name); do + echo "== $p ==" + kubectl -n "$NS" describe "$p" | sed -n '/^Events:/,$p' + for name in $(kubectl -n "$NS" get "$p" -o jsonpath='{.spec.initContainers[*].name} {.spec.containers[*].name}'); do + echo "--- $p [$name] ---" + kubectl -n "$NS" logs "$p" -c "$name" --tail=80 2>&1 || true + kubectl -n "$NS" logs "$p" -c "$name" --previous --tail=80 2>&1 || true + done + done + + - name: Roll back + if: failure() && steps.rollout.outcome == 'failure' + run: | + kubectl -n "$NS" rollout undo deploy/claudio || true + kubectl -n "$NS" rollout status deploy/claudio --timeout=420s || true + + - name: Remove kubeconfig + if: always() + run: rm -f "${KUBECONFIG:-$RUNNER_TEMP/kubeconfig}" + + - name: Summary + if: success() + run: | + { + echo "## Deployed ${TAG}" + echo "- ${{ steps.origin.outputs.url }}" + echo '```' + echo "image: ghcr.io/${{ github.repository }}:${TAG} (web + worker)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8a8604 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +runs/ +.env +.env.* +!.env.example +*.log +.DS_Store +claudio.config.local.json + +.scratch/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..67ba587 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "claudio": { + "command": "node", + "args": ["--import", "tsx", "packages/browser/src/tools/stdio-server.ts"], + "env": { + "CLAUDIO_RUN_DIR": "${CLAUDIO_RUN_DIR}", + "CLAUDIO_INSTANCE_URL": "${CLAUDIO_INSTANCE_URL}", + "CLAUDIO_CONFIG": "${CLAUDIO_CONFIG}", + "CLAUDIO_DRIVER": "${CLAUDIO_DRIVER}", + "CLAUDIO_CDP_ENDPOINT": "${CLAUDIO_CDP_ENDPOINT}" + } + } + } +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4c2f52b --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=true +strict-peer-dependencies=false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3e1167c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# One image, two commands (like automation-runner): web (default CMD) and worker. +# Based on Playwright's image: Chromium and its system libraries for the review browser, +# plus git for checking out pull request branches. Claude Code comes with the Agent SDK +# (platform package installed by pnpm), no global install needed. + +# ---- Base ---------------------------------------------------------------- +FROM mcr.microsoft.com/playwright:v1.64.0-noble AS base +ENV PNPM_HOME=/pnpm \ + CI=true \ + COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable +WORKDIR /app + +# ---- Build --------------------------------------------------------------- +FROM base AS build +# Dependencies first for layer caching: fetch only needs the lockfile. +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +RUN pnpm fetch +COPY . . +RUN pnpm install --frozen-lockfile --offline +# Shown nowhere yet, but handy in `kubectl exec ... env` and the image labels. +ARG GIT_SHA= +ARG GIT_REF= +ENV GIT_SHA=$GIT_SHA \ + GIT_REF=$GIT_REF +RUN pnpm --filter @claudio/web build + +# ---- Runtime ------------------------------------------------------------- +FROM base AS runtime +ENV NODE_ENV=production \ + PORT=3000 \ + HOST=0.0.0.0 \ + TZ=Europe/Berlin \ + CLAUDIO_ROOT=/app \ + CLAUDIO_RUNS_DIR=/data/runs +# The worker runs the TypeScript sources with tsx, so the installed workspace is kept as is. +COPY --from=build --chown=1001:1001 /app /app +RUN mkdir -p /data/runs && chown -R 1001:1001 /data +USER 1001 +EXPOSE 3000 +# Web (default). Worker: node_modules/.bin/tsx apps/worker/src/main.ts +# Migrations: node_modules/.bin/tsx packages/db/src/migrate.ts +CMD ["node", "apps/web/build"] diff --git a/README.md b/README.md index 2b4cd81..b6c9e70 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,117 @@ # Claudio -Agentic frontend test suite. + +Agentic user review for web apps. Claudio takes over the manual acceptance review of a feature branch: it reads the pull request and its linked GitHub issues, derives the acceptance criteria, logs into a running instance of the branch, verifies the criteria in the browser, checks the areas the diff affects for regressions, judges UX and UI, and writes a report with evidence. + +Built on the Claude Agent SDK, Playwright MCP and the Claude in Chrome extension. See `docs/ARCHITECTURE.md` for the full plan. + +## Status + +- Phase 0: the `claudio review` CLI runs one PR review end to end with the Playwright driver and writes a Markdown/JSON report. +- Web UI (first version): GitHub login, add repositories from your GitHub account, configure roles, start reviews, watch them live, read the report with evidence, and confirm or dismiss findings. Reviews run in a separate worker process; state lives in MySQL. +- Phase 3, PR review: on the standard and thorough budgets the orchestrator delegates to separate subagents, one after the other (they share one browser): `ac-verifier` for the acceptance criteria, `regression-tester` for the affected areas against the app map, `ux-reviewer` for the screenshots. The quick budget keeps one agent. While a review or analysis runs, the web UI shows a live view of the agent's browser (Chromium screencast, no model tokens). After rating the findings, the TL;DR comment can be posted to the PR from the UI, and the report downloaded as Markdown, both honouring the chosen strictness and leaving out dismissed findings. PR comments link to the review in Claudio (`PUBLIC_BASE_URL`). +- Phase 4, test workflows: per repository, tests described in plain language (flow, expectation, roles). The first run records the flow: an agent performs it exclusively through the `test_step` tool, which Claudio executes itself with Playwright and records, so the stored steps are exactly what ran (role/name locators, `{{run}}` and `{{date:+N:FORMAT}}` templates for unique data). Later runs replay the steps without a model; deterministic tests cost nothing, semi-deterministic ones are judged afterwards, explorative ones run with an agent each time. When a replayed step can no longer be executed, an agent heals the flow from that step, the healed steps become a new version, and a finding records the UI change. An independent judge (structured output) compares the final state with the expectation. Tests run on their own, as a suite against an instance, or with every PR review (a failed regression test turns an approval into "changes requested", also in the PR comment). Runs can be compared step by step. +- Phase 5, GitHub automation: per repository a webhook (set up from the settings with one click; needs admin rights and a public `PUBLIC_BASE_URL`) starts reviews on a `/claudio review [quick|standard|thorough]` comment, the `claudio-review` label, or automatically on every opened/updated PR, and can update the knowledge base on pushes to the default branch. The PR instance URL comes from a per-repo template (`https://pr-{pr}.preview.example.de`, also `{branch_slug}`, `{branch}`, `{sha}`). Results go back as the commit status "Claudio" (usable as a required check) and as the TL;DR comment, posted after the regression tests so it includes them. Automated runs act with the GitHub token of the person who set up the webhook. Findings dismissed with a reason become lessons that every later review of the repo is told about. +- Human takeover: Claudio never solves CAPTCHAs or bot checks itself. When one appears (reCAPTCHA, hCaptcha, Cloudflare Turnstile, Google's bot check, "Ich bin kein Roboter" pages), the run pauses: replays wait automatically, agents call `request_human_help`. Every page shows "Claudio wartet auf dich"; on the run page the live view becomes clickable, a person solves the challenge (click, type, keys, scroll are replayed in the run's browser) and presses "Erledigt, weitermachen". If the challenge is still there, Claudio asks again; without an answer within `CLAUDIO_HUMAN_TIMEOUT_MIN` (default 15) the run gives up cleanly. For your own apps, prefer the CAPTCHA providers' test keys on staging and PR instances. +- Anthropic keys per repository: a repository admin stores the repository's own Anthropic API key (encrypted, checked against Anthropic on save, only the last characters are shown). Every model session of that repository runs on this key alone; other Anthropic credentials in the environment are removed, so a run can neither use a server-wide key nor the machine's Claude login. Without a key, reviews, analyses, recordings and judged test runs do not start (deterministic replays still run). A key can be copied from another repository where the person is admin, and changed or removed later. The offline demo repository is the only exception on the dev server. +- Permissions follow GitHub: a person sees a repository in Claudio (with its reviews, tests, knowledge, files and costs) only if they can read it on GitHub. Write access is needed to start reviews, analyses and tests, rate findings, post comments and take over the browser; admin access for settings, role credentials, automation and the webhook. Permissions are fetched per user and cached for 15 minutes (fresh after every login). Expiring OAuth tokens are renewed automatically; if GitHub rejects a token, every page asks the person to sign in again. +- Costs: the "Kosten" page sums model costs per repository, month and kind of run (Agent SDK list-price estimates). Cancelling a run first asks the agent to stop (every further tool call is denied), so the SDK still reports the real cost; only if it does not finish within a minute is the process killed. +- Phase 2, repository analysis: Claudio reads the code and walks the running app, then writes a knowledge base per repository (overview, modules, routes, app map with the expected behaviour of every screen, notes). Every review receives the latest knowledge base. Updates only re-check what changed since the analysed commit. + +## Setup + +```sh +pnpm install +cp .env.example .env # ANTHROPIC_API_KEY, GITHUB_TOKEN, role credentials +cp claudio.config.example.json claudio.config.json +``` + +`claudio.config.json` describes the target repository: the roles the agent may log in as (credentials are referenced by environment variable name and never reach the model), optional login recipes, and notes about the application. + +Chromium: Playwright's bundled Chromium is used by default. Install it from the package that depends on Playwright: `pnpm --filter @claudio/browser exec playwright install chromium`. Set `CLAUDIO_CHROMIUM_PATH` to use another binary. + +## Web UI + +```sh +pnpm db:up # MySQL 8 in Docker on port 3310 (docker-compose.yml) +pnpm db:migrate +pnpm dev # web UI on http://localhost:5173 and the review worker +``` + +The `.env` needs `DATABASE_URL`, `CLAUDIO_SECRET_KEY` (`openssl rand -base64 32`) and a GitHub OAuth App: + +1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App. +2. Homepage URL `http://localhost:5173`, authorization callback URL `http://localhost:5173/auth/github/callback`. +3. Put the client ID and a generated client secret into `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`. + +Claudio asks for the scopes `repo read:user read:org`. Your token is stored encrypted and is used to list your repositories and open pull requests, to clone the PR branch, and to post the optional PR comment. Restrict who may sign in with `CLAUDIO_ALLOWED_GITHUB_ORGS` or `CLAUDIO_ALLOWED_GITHUB_USERS`. + +Per repository you configure notes for the agent, the default instance URL, extra allowed hosts and the roles (username, password, optional login recipe). Role credentials are encrypted with `CLAUDIO_SECRET_KEY` and are handed only to the `login_as` tool, never to the model. A review can start only after someone confirms that the instances hold test data only. + +The worker (`pnpm worker`) picks up queued reviews, one at a time by default (`CLAUDIO_WORKER_CONCURRENCY`). Web and worker share the database and the `runs/` directory; the web UI serves screenshots and traces from there to signed-in users. + +Repository analysis: open a repository, tab "Wissen", and start an analysis against a running instance of the main branch (e.g. staging). The `code-scanner` subagent maps the code; the orchestrator walks the app screen by screen and writes the documents with the `write_knowledge` tool. The tab shows when the analysed commit is behind the branch head; "Wissensbasis aktualisieren" then hands the agent the previous documents and the list of changed files. Reviews get the route index and app map inlined in the prompt when they are small (up to about 24,000 characters) and as files otherwise. + +Webhook security: the endpoint `/webhooks/github` is public; every delivery must carry a valid `X-Hub-Signature-256` for the repository's own secret (generated on setup, stored encrypted), otherwise it is rejected with 401. For local development, expose the dev server with a tunnel (e.g. cloudflared) and set `PUBLIC_BASE_URL` to the tunnel address before setting up the webhook. + +Offline demo: with `CLAUDIO_DEV_FIXTURES=1` the dev server offers a login without GitHub and a demo repository that reviews the fixture PR against `apps/demo-target` (start it with `pnpm demo-target`). + +Production: `pnpm --filter @claudio/web build`, then `node apps/web/build` (set `ORIGIN` and `PORT`) next to `pnpm worker`. + +## Running a review + +```sh +pnpm claudio review \ + --repo vanderlars/some-app \ + --pr 123 \ + --url https://pr-123.preview.example.de \ + --role dispatcher \ + --budget standard \ + --verbosity normal \ + --strictness normal \ + --comment # optional: post a TL;DR comment to the PR +``` + +Options: + +| Flag | Values | Default | +|---|---|---| +| `--driver` | `playwright` (unattended, headless), `chrome` (live mode, experimental) | `playwright` | +| `--budget` | `quick`, `standard`, `thorough` | `standard` | +| `--verbosity` | `tldr`, `normal`, `detailed` | `normal` | +| `--strictness` | `lenient`, `normal`, `strict` | `normal` | +| `--lang` | `de`, `en` | `de` | +| `--headed` | show the browser window | off | +| `--no-clone` | skip checking out the PR branch for code analysis | clone | + +Every run writes to `runs//`: `report.md` (at the chosen verbosity), `report-detailed.md`, `report.json`, `findings.json`, `acceptance.json`, `evidence/`, `console.json`, `network.json`, `trace.zip` (Playwright trace), `log.jsonl` (agent transcript) and `prompt.md`. + +## Live mode with Claude in Chrome + +The live driver lets a person watch and step in. It runs through the Claude Code harness with the Chrome extension and needs a Claude subscription login on the machine. From this repository: + +```sh +export CLAUDIO_RUN_DIR=$PWD/runs/live-1 CLAUDIO_INSTANCE_URL=https://pr-123.preview.example.de CLAUDIO_CONFIG=$PWD/claudio.config.json CLAUDIO_DRIVER=chrome +claude --chrome +``` + +The `.mcp.json` in this repository registers the Claudio tool server, and `.claude/agents` and `.claude/skills` hold the same review procedure the unattended driver uses. Whether the Agent SDK can start this mode itself (`--driver chrome`) is still being verified. + +## Trying it without a real deployment + +`apps/demo-target` is a tiny dispatch app with deliberate bugs plus a fixture PR context. See its README; the review runs fully offline except for the model calls. + +## Deployment + +Kubernetes, built and deployed by GitHub Actions on every push to `main`; pull requests run the same checks. See `docs/DEPLOYMENT.md` for the setup and operations. + +## Development + +```sh +pnpm typecheck +pnpm test # unit tests +CLAUDIO_INTEGRATION=1 pnpm test # also the Playwright driver integration test (needs Chromium) +``` + +Packages: `core` (schemas, budget tiers, report rendering), `github` (PR and issue context), `browser` (drivers, capture, login, Claudio tools), `agent` (Agent SDK runner, prompts, hooks), `db` (Drizzle schema, migrations, queries, credential encryption), `apps/cli`, `apps/web` (SvelteKit UI), `apps/worker` (review runner). + +After changing `packages/db/src/schema.ts`, run `pnpm db:generate` and commit the new migration. diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..d20a5fe --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "@claudio/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "claudio": "./src/main.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@claudio/agent": "workspace:*", + "@claudio/browser": "workspace:*", + "@claudio/core": "workspace:*", + "commander": "^15.0.0" + } +} diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts new file mode 100644 index 0000000..8488bc9 --- /dev/null +++ b/apps/cli/src/main.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { Command, InvalidArgumentError } from "commander"; +import { runReview } from "@claudio/agent"; +import { BudgetTierSchema, DriverNameSchema, LanguageSchema, StrictnessSchema, VerbositySchema, blockingFindings, verdict } from "@claudio/core"; + +if (existsSync(".env")) { + process.loadEnvFile(".env"); +} + +const program = new Command(); +program.name("claudio").description("Agentic user review for web apps").version("0.1.0"); + +function enumParser(name: string, values: readonly string[]) { + return (v: string) => { + if (!values.includes(v)) throw new InvalidArgumentError(`${name} must be one of ${values.join(", ")}`); + return v; + }; +} + +program + .command("review") + .description("Review a pull request against a running instance of the app") + .requiredOption("--repo ", "GitHub repository") + .requiredOption("--pr ", "Pull request number", (v) => { + const n = Number(v); + if (!Number.isInteger(n) || n <= 0) throw new InvalidArgumentError("pr must be a positive integer"); + return n; + }) + .requiredOption("--url ", "Base URL of the deployed PR branch") + .option("--role ", "Role(s) to start with (must be configured in claudio.config.json)") + .option("--driver ", "playwright (unattended) or chrome (live, Claude in Chrome)", enumParser("driver", DriverNameSchema.options), "playwright") + .option("--budget ", "quick | standard | thorough", enumParser("budget", BudgetTierSchema.options), "standard") + .option("--verbosity ", "tldr | normal | detailed", enumParser("verbosity", VerbositySchema.options), "normal") + .option("--strictness ", "lenient | normal | strict", enumParser("strictness", StrictnessSchema.options), "normal") + .option("--lang ", "Report language: de | en", enumParser("lang", LanguageSchema.options), "de") + .option("--headed", "Show the browser window (playwright driver)", false) + .option("--comment", "Post a TL;DR comment to the pull request", false) + .option("--no-clone", "Skip cloning the PR branch for code analysis") + .option("--model ", "Override the orchestrator model") + .option("--instructions ", "Extra instructions for this run") + .option("--pr-context ", "Use a PullRequestContext JSON file instead of GitHub (offline runs)") + .option("--repo-dir ", "Use an existing checkout instead of cloning the PR branch") + .option("--config ", "Path to claudio.config.json", "claudio.config.json") + .option("--runs ", "Directory for run outputs", "runs") + .action(async (opts) => { + const abort = new AbortController(); + process.on("SIGINT", () => { + console.error("\naborting run..."); + abort.abort(); + }); + const report = await runReview( + { + repo: opts.repo, + pr: opts.pr, + instanceUrl: opts.url, + roles: opts.role ?? [], + driver: opts.driver, + budget: opts.budget, + verbosity: opts.verbosity, + strictness: opts.strictness, + language: opts.lang, + headed: opts.headed, + postComment: opts.comment, + cloneRepo: opts.clone, + modelOverride: opts.model, + extraInstructions: opts.instructions, + }, + { + runsRoot: resolve(opts.runs), + configPath: opts.config, + prContextFile: opts.prContext, + repoDir: opts.repoDir, + projectRoot: process.cwd(), + abortController: abort, + onProgress: (line) => console.error(`[claudio] ${line}`), + }, + ); + const blocking = blockingFindings(report); + console.log(""); + console.log(`Verdict: ${verdict(report)} (${report.status})`); + console.log(`Acceptance criteria: ${report.acceptanceCriteria.filter((a) => a.status === "passed").length}/${report.acceptanceCriteria.length} passed`); + console.log(`Findings: ${report.findings.length} (blocking: ${blocking.length})`); + console.log(`Cost: ${report.cost.usd.toFixed(2)} USD, ${report.cost.turns} turns, ${report.cost.browserSteps} browser steps, ${Math.round(report.cost.durationMs / 1000)}s`); + console.log(`Report: ${resolve(opts.runs)}/${report.runId}/report.md`); + process.exitCode = report.status === "completed" ? 0 : 2; + }); + +program.parseAsync(process.argv).catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/demo-target/README.md b/apps/demo-target/README.md new file mode 100644 index 0000000..75b1d34 --- /dev/null +++ b/apps/demo-target/README.md @@ -0,0 +1,21 @@ +# Demo target + +A deliberately buggy mini dispatch app used to verify Claudio end to end without a real repository or deployment. + +```sh +node apps/demo-target/src/server.mjs # http://127.0.0.1:4310 +export DEMO_DISPATCHER_USER=dispo@example.test DEMO_DISPATCHER_PASSWORD=dispo +export DEMO_ADMIN_USER=admin@example.test DEMO_ADMIN_PASSWORD=admin +pnpm claudio review --repo vanderlars/dispo-demo --pr 17 --url http://127.0.0.1:4310 \ + --pr-context apps/demo-target/fixtures/pr-context.json --repo-dir apps/demo-target \ + --config apps/demo-target/claudio.config.json --role dispatcher --budget quick +``` + +Bugs a good review must find (see `fixtures/pr-context.json` for the PR and issue): + +1. Search in the trip list returns HTTP 500 once a trip without a vehicle exists (every trip created through the new form). +2. The new form lacks the required vehicle field, allows past dates, has unlabeled Von/Nach inputs and a button labelled "Senden" instead of "Speichern". +3. The server does not validate required fields. +4. The trip list logs a console error on every load. + +Bot check fixture: `/geschuetzt` redirects to `/pruefung` ("Ich bin kein Roboter") until a person confirms it. It is not a bug to find; it exercises Claudio's human takeover. diff --git a/apps/demo-target/claudio.config.json b/apps/demo-target/claudio.config.json new file mode 100644 index 0000000..07adc17 --- /dev/null +++ b/apps/demo-target/claudio.config.json @@ -0,0 +1,8 @@ +{ + "repo": "vanderlars/dispo-demo", + "notes": "Demo-Dispositionsapp. Rollen: Disponent (legt Fahrten an), Admin. Datum im Format JJJJ-MM-TT.", + "roles": { + "dispatcher": { "description": "Disponent", "usernameEnv": "DEMO_DISPATCHER_USER", "passwordEnv": "DEMO_DISPATCHER_PASSWORD", "loginPath": "/login", "successUrlContains": "/dashboard" }, + "admin": { "description": "Administrator", "usernameEnv": "DEMO_ADMIN_USER", "passwordEnv": "DEMO_ADMIN_PASSWORD", "loginPath": "/login", "successUrlContains": "/dashboard" } + } +} diff --git a/apps/demo-target/fixtures/example-report.md b/apps/demo-target/fixtures/example-report.md new file mode 100644 index 0000000..aac6c96 --- /dev/null +++ b/apps/demo-target/fixtures/example-report.md @@ -0,0 +1,181 @@ +# Claudio Review: PR #17 Fahrten: Neue Fahrt anlegen und Suche in der Fahrtenliste + +**Änderungen nötig** (Status: completed, strictness: normal) + +## Zusammenfassung + +**Urteil:** PR #17 sollte nicht gemergt werden. Drei von sechs Akzeptanzkriterien sind nicht erfüllt, und die neue Suche bricht mit einem Serverfehler ab, sobald jemand über das neue Formular eine Fahrt angelegt hat. + +**Zusammenfassung** + +Was funktioniert: Der Button „Neue Fahrt“ öffnet das Formular. Nach dem Speichern erscheint die Fahrt mit Erfolgsmeldung in der Liste, getestet als Disponent und als Admin. Ohne Anmeldung leitet die App beim Anlegen auf den Login um. Mit den beiden vorhandenen Fahrten lieferte die Suche nach Fahrgast und Fahrzeug zunächst richtige Ergebnisse. + +Das Formular hat aber kein Feld „Fahrzeug“, deshalb wird jede neue Fahrt ohne Fahrzeug gespeichert. Die Suche verkraftet das nicht: Ab der ersten neuen Fahrt endet sie mit HTTP 500 („Interner Fehler“). Seit einer Fahrt mit leerem Fahrgast scheitert sogar jede Suche, egal mit welchem Begriff. Daneben fehlt die Prüfung auf Daten in der Vergangenheit, und der Server prüft die Eingaben überhaupt nicht. Eingaben werden außerdem ungeprüft als HTML in die Liste geschrieben, wodurch fremder Code eingeschleust werden kann (Stored XSS). + +Wichtigste Beobachtungen: +- **Blocker:** Die Suche liefert HTTP 500, sobald eine Fahrt ohne Fahrzeug existiert. Die interne Fehlermeldung wird dem Nutzer angezeigt (F1). +- **Major:** Das Pflichtfeld „Fahrzeug“ fehlt im Formular (F3). +- **Major:** Ein Datum in der Vergangenheit wird ohne Fehlermeldung gespeichert (F2). +- **Major:** Der Server prüft die Eingaben nicht. Eine Fahrt ohne Fahrgast, Datum und Uhrzeit wurde gespeichert (F5). +- **Major (Sicherheit):** Fahrgast, Von und Nach werden ohne HTML-Escaping in die Liste geschrieben; das ist im Browser nachgewiesen. Laut Code gilt das auch für den Suchbegriff im Suchfeld (F4). +- **Minor:** Von und Nach haben keine Beschriftung, die übrigen Beschriftungen sind nicht mit ihren Feldern verknüpft, und der Button heißt „Senden“ (F6). Der Suchbegriff wird kleingeschrieben zurück ins Feld gesetzt (F7). Bei jedem Aufruf der Liste erscheint ein Konsolenfehler (F8). + +**Affected areas:** +- Fahrtenliste `/trips`: neue Suche, Erfolgsmeldung und Button „Neue Fahrt“ +- Formular „Neue Fahrt“ `GET /trips/new` +- Speichern einer Fahrt `POST /trips/new`: Validierung und Rechteprüfung +- Fahrtenliste als Anzeige aller Fahrten, über die sich eingeschleuster Code auswirkt + +**Nicht oder nur eingeschränkt getestet** +- **Rechte für weitere Rollen:** Die Instanz hat nur Disponent und Admin, deshalb ließ sich nicht prüfen, ob andere Rollen abgewiesen werden. Im Code prüft nur der Formularaufruf die Rolle, das Speichern nicht. +- **Code im Suchbegriff:** Im Browser ließ sich das nicht vorführen, weil die Suche inzwischen immer mit 500 abbricht. Der Befund stützt sich hier auf den Code. + +**Testdaten:** Ich habe die Fahrten #3 bis #6 angelegt, erkennbar am Präfix `claudio-review-pr17-20261010-183117-7fng`; Fahrt #4 hat absichtlich einen leeren Fahrgast. Solange sie bestehen, liefert jede Suche HTTP 500. Die App kann keine Fahrten löschen, deshalb verschwinden sie erst, wenn der Server neu gestartet wird. + +## Acceptance Criteria + +| # | Criterion | Status | Notes | +|---|---|---|---| +| 1 | In der Fahrtenliste gibt es einen Button "Neue Fahrt", der ein Formular öffnet. | erfüllt | Button "Neue Fahrt" über der Tabelle vorhanden, Klick öffnet /trips/new mit Formular. | +| 2 | Das Formular hat die Felder Fahrgast, Datum, Uhrzeit, Von, Nach und Fahrzeug. Fahrgast, Datum, Uhrzeit und Fahrzeug sind Pflichtfelder. | nicht erfüllt | Feld Fahrzeug fehlt komplett (F3). Fahrgast/Datum/Uhrzeit sind nur clientseitig über required Pflicht, ein leeres Absenden blieb im Browser auf der Seite. Serverseitig gibt es keine Prüfung (F5). | +| 3 | Das Datum darf nicht in der Vergangenheit liegen; sonst erscheint eine Fehlermeldung am Feld. | nicht erfüllt | 2026-10-01 (heute 2026-10-10) wurde ohne Fehlermeldung gespeichert (F2). | +| 4 | Nach dem Speichern erscheint die neue Fahrt in der Liste und eine Erfolgsmeldung wird angezeigt. | erfüllt | Als Disponent (#3) und als Admin (#6) geprüft: Weiterleitung auf die Liste, neue Zeile sichtbar, Meldung "Fahrt #n angelegt.". Fahrzeug bleibt dabei "-" (F3). | +| 5 | Die Fahrtenliste lässt sich nach Fahrgast oder Fahrzeug durchsuchen. | nicht erfüllt | Mit den Bestandsdaten funktionierte die Suche nach Fahrgast und Fahrzeug (E1, E2). Sobald eine über das Formular angelegte Fahrt (Fahrzeug null) existiert, endet die Suche mit HTTP 500 (F1). Seit einer Fahrt mit leerem Fahrgast scheitert jede nicht-leere Suche. | +| 6 | Nur Disponenten und Admins dürfen Fahrten anlegen. | erfüllt | Disponent und Admin können anlegen. Ohne Anmeldung werden GET und POST /trips/new auf /login umgeleitet, es entstand keine Fahrt (per /api/trips geprüft). Einschränkung: Andere Rollen gibt es in der Instanz nicht. POST /trips/new hat keine eigene Rollenprüfung (nur GET), das würde bei weiteren Rollen zur Lücke. | + +## Findings + +### Blockierend (5) + +### [Blocker] Suche in der Fahrtenliste liefert HTTP 500, sobald eine Fahrt ohne Fahrzeug existiert + +_acceptance_ · http://127.0.0.1:4310/trips?q=mia · AC: Die Fahrtenliste lässt sich nach Fahrgast oder Fahrzeug durchsuchen. + +Über das neue Formular angelegte Fahrten haben immer vehicle = null (kein Fahrzeugfeld). Der neue Suchfilter ruft t.vehicle.toLowerCase() ohne Null-Prüfung auf (src/server.mjs:60). Jede Suche, deren Begriff nicht schon beim Fahrgast dieser Fahrt trifft, endet daher mit "Interner Fehler – Cannot read properties of null (reading 'toLowerCase')". Da jede neue Fahrt dieses Problem auslöst, ist die Suche nach der ersten angelegten Fahrt praktisch unbenutzbar. Zusätzlich wird die interne Fehlermeldung dem Nutzer angezeigt. + +**Schritte** +1. Als Disponent einloggen +2. Fahrten > Neue Fahrt, Pflichtfelder ausfüllen, Senden +3. In der Fahrtenliste nach "mia" suchen (vorher funktionierte das) + +**Erwartet:** Fahrt #1 Mia Muster wird gefunden; Fahrten ohne Fahrzeug werden einfach nicht gematcht. +**Beobachtet:** HTTP 500, Seite "Interner Fehler" mit JS-Fehlermeldung. + +**Evidenz:** [Suche nach Fahrgast "mia" (vor Anlegen neuer Fahrten)](evidence/E1-Suche_nach_Fahrgast_mia_vor_Anlegen_neuer_Fahrten_.png), [Suche "mia" nach Anlegen einer Fahrt: HTTP 500](evidence/E5-Suche_mia_nach_Anlegen_einer_Fahrt_HTTP_500.png) + +### [Major] Datum in der Vergangenheit wird ohne Fehlermeldung gespeichert + +_acceptance_ · http://127.0.0.1:4310/trips/new · AC: Das Datum darf nicht in der Vergangenheit liegen; sonst erscheint eine Fehlermeldung am Feld. + +Das Datumsfeld hat kein min-Attribut und der Server prüft das Datum nicht. Eine Fahrt mit Datum 2026-10-01 (heute: 2026-10-10) wurde gespeichert und als Fahrt #3 in der Liste angezeigt. + +**Schritte** +1. Neue Fahrt öffnen +2. Fahrgast, Datum 2026-10-01, Uhrzeit 08:00 eingeben +3. Senden + +**Erwartet:** Fehlermeldung am Feld Datum, Fahrt wird nicht gespeichert. +**Beobachtet:** Fahrt wird gespeichert, Erfolgsmeldung "Fahrt #3 angelegt." + +**Evidenz:** [Fahrt mit Vergangenheitsdatum gespeichert](evidence/E4-Fahrt_mit_Vergangenheitsdatum_gespeichert.png) + +### [Major] Pflichtfeld "Fahrzeug" fehlt im Formular "Neue Fahrt" + +_acceptance_ · http://127.0.0.1:4310/trips/new · AC: Das Formular hat die Felder Fahrgast, Datum, Uhrzeit, Von, Nach und Fahrzeug. + +Das Formular enthält nur Fahrgast, Datum, Uhrzeit, Von, Nach. Ein Fahrzeugfeld existiert nicht; der Server setzt vehicle immer auf null, in der Liste erscheint "-". Damit kann eine Fahrt nicht vollständig angelegt werden, und genau diese Fahrten lösen den 500-Fehler der Suche aus. + +**Erwartet:** Pflichtfeld Fahrzeug im Formular. +**Beobachtet:** Kein Fahrzeugfeld, Fahrzeug immer leer. + +**Evidenz:** [Formular Neue Fahrt](evidence/E3-Formular_Neue_Fahrt.png), [Fahrt mit Vergangenheitsdatum gespeichert](evidence/E4-Fahrt_mit_Vergangenheitsdatum_gespeichert.png) + +### [Major] Kein HTML-Escaping: Eingaben aus "Neue Fahrt" werden als HTML in der Fahrtenliste gerendert (Stored XSS) + +_security_ · http://127.0.0.1:4310/trips + +Fahrgast, Von und Nach werden ungefiltert in die Tabelle der Fahrtenliste eingesetzt (src/server.mjs:69). Ein Fahrgast mit Markup <i id="claudio-xss">…</i> erscheint als echtes <i>-Element im DOM. Damit kann jeder, der Fahrten anlegen darf, Skripte einschleusen, die bei allen anderen Nutzern der Fahrtenliste laufen. Gleiches gilt laut Code für den Suchbegriff q, der unescaped in das value-Attribut des Suchfelds geschrieben wird (Reflected XSS); im Browser konnte das nicht vorgeführt werden, weil die Suche derzeit mit 500 abbricht (F1). + +**Schritte** +1. Fahrt mit Fahrgast Test anlegen +2. Fahrtenliste öffnen +3. document.getElementById('claudio-xss') existiert / Text wird kursiv dargestellt + +**Erwartet:** Eingaben werden HTML-kodiert und als Text angezeigt. +**Beobachtet:** Markup wird als HTML interpretiert. + +**Evidenz:** [Liste mit leerer Fahrt #4 und HTML-injizierter Fahrt #5](evidence/E6-Liste_mit_leerer_Fahrt_4_und_HTML-injizierter_Fahrt_5.png) + +### [Major] Keine serverseitige Validierung: Fahrt ohne Fahrgast, Datum und Uhrzeit wird gespeichert + +_acceptance_ · http://127.0.0.1:4310/trips/new · AC: Fahrgast, Datum, Uhrzeit und Fahrzeug sind Pflichtfelder. + +POST /trips/new prüft weder Pflichtfelder noch Datum. Die Pflichtfelder werden nur über das HTML-Attribut required im Browser erzwungen. Ein direkter POST mit leerem Fahrgast, Datum und Uhrzeit wurde als Fahrt #4 gespeichert. Diese leere Fahrt sorgt außerdem dafür, dass jede Suche mit 500 abbricht (siehe F1). + +**Schritte** +1. Eingeloggt POST /trips/new mit passenger=&date=&time= senden +2. Fahrtenliste öffnen + +**Erwartet:** Server lehnt ab und zeigt Feldfehler an. +**Beobachtet:** Redirect auf /trips?created=4, leere Zeile in der Liste. + +**Evidenz:** [Liste mit leerer Fahrt #4 und HTML-injizierter Fahrt #5](evidence/E6-Liste_mit_leerer_Fahrt_4_und_HTML-injizierter_Fahrt_5.png), [Suche "claudio" ebenfalls HTTP 500](evidence/E7-Suche_claudio_ebenfalls_HTTP_500.png) + +### Nicht blockierend (4) + +### [Minor] Formular "Neue Fahrt": Von/Nach ohne Labels, Labels nicht mit Feldern verknüpft, Button heißt "Senden" + +_ux_ · http://127.0.0.1:4310/trips/new + +Von und Nach haben nur Platzhalter und stehen nebeneinander statt wie die anderen Felder untereinander mit Label. Die Labels Fahrgast/Datum/Uhrzeit sind nicht per for/id verknüpft (input.labels.length = 0), Screenreader nennen die Felder nicht, und ein Klick aufs Label setzt keinen Fokus. Der Absenden-Button heißt "Senden", passender für das Anlegen wäre "Speichern" bzw. "Fahrt anlegen". Pflichtfelder sind nicht markiert. "Neue Fahrt" und "Zurück" sind \n+

\n@@ -60,0 +75,22 @@\n+ if (p === \"/trips/new\" && req.method === \"GET\") {\n+ return html(res, page(\"Neue Fahrt\", `

Neue Fahrt

\n+\n+\n+\n+ \n+

`, user));\n+ }\n+ if (p === \"/trips/new\" && req.method === \"POST\") {\n+ const b = await readBody(req);\n+ const trip = { id: trips.length + 1, passenger: b.get(\"passenger\") ?? \"\", date: b.get(\"date\") ?? \"\", time: b.get(\"time\") ?? \"\", from: b.get(\"from\") ?? \"\", to: b.get(\"to\") ?? \"\", vehicle: null };\n+ trips.push(trip);\n+ return redirect(res, `/trips?created=${trip.id}`);\n+ }" + } + ], + "issues": [ + { + "number": 16, + "title": "Disponent kann eine neue Fahrt anlegen", + "body": "Als Disponent möchte ich eine neue Fahrt anlegen, damit sie in der Fahrtenliste erscheint.\n\n**Acceptance Criteria**\n- In der Fahrtenliste gibt es einen Button \"Neue Fahrt\", der ein Formular öffnet.\n- Das Formular hat die Felder Fahrgast, Datum, Uhrzeit, Von, Nach und Fahrzeug. Fahrgast, Datum, Uhrzeit und Fahrzeug sind Pflichtfelder.\n- Das Datum darf nicht in der Vergangenheit liegen; sonst erscheint eine Fehlermeldung am Feld.\n- Nach dem Speichern erscheint die neue Fahrt in der Liste und eine Erfolgsmeldung wird angezeigt.\n- Die Fahrtenliste lässt sich nach Fahrgast oder Fahrzeug durchsuchen.\n- Nur Disponenten und Admins dürfen Fahrten anlegen.", + "url": "https://github.com/vanderlars/dispo-demo/issues/16", + "labels": ["feature"], + "state": "open" + } + ] +} diff --git a/apps/demo-target/package.json b/apps/demo-target/package.json new file mode 100644 index 0000000..eff139c --- /dev/null +++ b/apps/demo-target/package.json @@ -0,0 +1,11 @@ +{ + "name": "@claudio/demo-target", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Tiny dispatch app with deliberate bugs, used to verify Claudio itself", + "scripts": { + "start": "node src/server.mjs", + "typecheck": "echo skip" + } +} diff --git a/apps/demo-target/src/server.mjs b/apps/demo-target/src/server.mjs new file mode 100644 index 0000000..faf53ec --- /dev/null +++ b/apps/demo-target/src/server.mjs @@ -0,0 +1,109 @@ +// Minimal "Disposition" app: login, trip list, trip creation. Contains deliberate bugs +// that a review must find (see fixtures/pr-context.json for the PR under review). +import { createServer } from "node:http"; + +const USERS = { "dispo@example.test": { password: "dispo", role: "dispatcher", name: "Dana Dispo" }, "admin@example.test": { password: "admin", role: "admin", name: "Alex Admin" } }; +const sessions = new Map(); +let trips = [ + { id: 1, passenger: "Mia Muster", date: "2026-10-12", time: "07:30", from: "Hauptstr. 1", to: "Grundschule Nord", vehicle: "SLN-1" }, + { id: 2, passenger: "Ben Beispiel", date: "2026-10-12", time: "13:15", from: "Grundschule Nord", to: "Hauptstr. 1", vehicle: "SLN-1" }, +]; + +const page = (title, body, user) => `${title} · Dispo Demo + +
Dispo DemoÜbersichtFahrten${user ? `${user.name} (${user.role})Abmelden` : ""}
${body}
`; + +function parseCookies(req) { + return Object.fromEntries((req.headers.cookie ?? "").split(";").map((c) => c.trim().split("=")).filter((p) => p[0])); +} +function currentUser(req) { + const sid = parseCookies(req).sid; + return sid ? sessions.get(sid) : undefined; +} +function readBody(req) { + return new Promise((resolve) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => resolve(new URLSearchParams(b))); }); +} +function redirect(res, to) { res.statusCode = 302; res.setHeader("location", to); res.end(); } +function html(res, s, status = 200) { res.statusCode = status; res.setHeader("content-type", "text/html; charset=utf-8"); res.end(s); } +function json(res, data, status = 200) { res.statusCode = status; res.setHeader("content-type", "application/json"); res.end(JSON.stringify(data)); } + +const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://x"); + const user = currentUser(req); + const p = url.pathname; + + if (p === "/" ) return redirect(res, user ? "/dashboard" : "/login"); + if (p === "/login" && req.method === "GET") { + return html(res, page("Anmelden", `

Anmelden

${url.searchParams.get("error") ? `` : ""}

`)); + } + if (p === "/login" && req.method === "POST") { + const b = await readBody(req); + const u = USERS[b.get("email") ?? ""]; + if (!u || u.password !== b.get("password")) return redirect(res, "/login?error=1"); + const sid = Math.random().toString(36).slice(2); + sessions.set(sid, u); + res.setHeader("set-cookie", `sid=${sid}; Path=/; HttpOnly`); + return redirect(res, "/dashboard"); + } + if (p === "/logout") { res.setHeader("set-cookie", "sid=; Path=/; Max-Age=0"); return redirect(res, "/login"); } + if (!user) return redirect(res, "/login"); + + if (p === "/dashboard") { + return html(res, page("Übersicht", `

Übersicht

Heute: ${trips.filter((t) => t.date === "2026-10-12").length} Fahrten.

Zu den Fahrten

`, user)); + } + if (p === "/api/stats") return json(res, { trips: trips.length }); + if (p === "/trips" && req.method === "GET") { + const q = (url.searchParams.get("q") ?? "").toLowerCase(); + // BUG 1: the new search filter throws on trips without a vehicle (null.toLowerCase) → 500 + let rows; + try { + rows = trips.filter((t) => !q || t.passenger.toLowerCase().includes(q) || t.vehicle.toLowerCase().includes(q)); + } catch (e) { + return html(res, page("Fehler", `

Interner Fehler

${e.message}
`, user), 500); + } + const created = url.searchParams.get("created"); + return html(res, page("Fahrten", `

Fahrten

${created ? `

Fahrt #${created} angelegt.

` : ""} +
+

+ +${rows.map((t) => ``).join("")} +
#FahrgastDatumUhrzeitVonNachFahrzeug
${t.id}${t.passenger}${t.date}${t.time}${t.from}${t.to}${t.vehicle ?? "-"}
`, user)); + } + if (p === "/trips/new" && req.method === "GET") { + if (user.role !== "dispatcher" && user.role !== "admin") return html(res, page("Verboten", "

Kein Zugriff

", user), 403); + // BUG 2 (UX): no field labels for Von/Nach, button says "Senden", no vehicle field although AC requires it, date accepts past dates + return html(res, page("Neue Fahrt", `

Neue Fahrt

+ + + + +

`, user)); + } + if (p === "/trips/new" && req.method === "POST") { + const b = await readBody(req); + const trip = { id: trips.length + 1, passenger: b.get("passenger") ?? "", date: b.get("date") ?? "", time: b.get("time") ?? "", from: b.get("from") ?? "", to: b.get("to") ?? "", vehicle: null }; + // BUG 3: server does not validate; empty passenger is accepted when the browser validation is bypassed + trips.push(trip); + return redirect(res, `/trips?created=${trip.id}`); + } + if (p === "/api/trips") return json(res, trips); + // Bot check fixture: /geschuetzt is only reachable after a person ticks "Ich bin kein Roboter". + // Used to verify Claudio's human takeover; a real app would show a CAPTCHA here. + if (p === "/geschuetzt") { + if (parseCookies(req).human !== "1") return redirect(res, "/pruefung?next=/geschuetzt"); + return html(res, page("Geschützt", "

Geschützter Bereich

Die Bot-Prüfung wurde bestanden.

", user)); + } + if (p === "/pruefung" && req.method === "GET") { + return html(res, page("Sicherheitsprüfung", `

Sicherheitsprüfung

Bitte bestätigen Sie, dass Sie ein Mensch sind.

`, user)); + } + if (p === "/pruefung" && req.method === "POST") { + const b = await readBody(req); + if (b.get("human") !== "1") return redirect(res, "/pruefung"); + res.setHeader("set-cookie", "human=1; Path=/; HttpOnly"); + return redirect(res, url.searchParams.get("next") ?? "/dashboard"); + } + return html(res, page("404", "

Seite nicht gefunden

", user), 404); +}); + +const port = Number(process.env.PORT ?? 4310); +server.listen(port, "127.0.0.1", () => console.log(`demo target on http://127.0.0.1:${port} (dispo@example.test/dispo, admin@example.test/admin)`)); diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/apps/web/.npmrc b/apps/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/apps/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..836c5fc --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "@claudio/web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev --port 5173 --strictPort", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold error", + "start": "node build" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^6.0.0", + "@sveltejs/kit": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^7.2.0", + "svelte": "^5.57.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.3.0" + }, + "imports": { + "#lib": "./src/lib/index.js", + "#lib/*": "./src/lib/*" + }, + "dependencies": { + "@claudio/core": "workspace:*", + "@claudio/db": "workspace:*", + "@claudio/github": "workspace:*", + "marked": "^18.1.0", + "octokit": "^5.0.5", + "zod": "^4.6.5", + "drizzle-orm": "^0.45.4" + } +} diff --git a/apps/web/src/app.css b/apps/web/src/app.css new file mode 100644 index 0000000..6c71103 --- /dev/null +++ b/apps/web/src/app.css @@ -0,0 +1,362 @@ +/* Claudio: Prüfprotokoll. Quiet, cool paper; signal colours reserved for severities and verdicts. */ +:root { + --paper: #f2f5f4; + --sheet: #ffffff; + --sunken: #e8edeb; + --ink: #1d2a27; + --ink-2: #4a5955; + --ink-3: #74827e; + --rule: #d6dedb; + --rule-strong: #b9c6c2; + --petrol: #0e5e6f; + --petrol-ink: #ffffff; + --petrol-soft: #dcecef; + + --pass: #1f7a4d; + --pass-soft: #e0f1e7; + --block: #b8322a; + --block-soft: #f8e3e1; + --major: #b4620f; + --major-soft: #f9ead9; + --minor: #6b6a1d; + --minor-soft: #f0efd9; + --hint: #4f6378; + --hint-soft: #e4eaf0; + + --font: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', system-ui, sans-serif; + --radius-s: 4px; + --radius-m: 8px; + --shadow: 0 1px 0 rgba(29, 42, 39, 0.06), 0 1px 3px rgba(29, 42, 39, 0.06); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --paper: #131a19; + --sheet: #1a2321; + --sunken: #101615; + --ink: #e3ebe8; + --ink-2: #b2c0bc; + --ink-3: #82918d; + --rule: #2b3734; + --rule-strong: #3d4c48; + --petrol: #5fb6c6; + --petrol-ink: #0c1f24; + --petrol-soft: #1c3439; + --pass: #5cc48f; + --pass-soft: #17322a; + --block: #f07b70; + --block-soft: #3b1f1d; + --major: #f0a457; + --major-soft: #3a2a17; + --minor: #cfcc72; + --minor-soft: #2f2e1b; + --hint: #9db2c7; + --hint-soft: #1f2a35; + --shadow: 0 1px 0 rgba(0, 0, 0, 0.3); + color-scheme: dark; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--font); + font-size: 1rem; + line-height: 1.5; + font-variant-numeric: tabular-nums; +} + +h1, +h2, +h3 { + line-height: 1.2; + margin: 0; + font-weight: 700; + letter-spacing: -0.01em; +} +h1 { + font-size: 1.75rem; +} +h2 { + font-size: 1.25rem; +} +h3 { + font-size: 1.0625rem; +} +p { + margin: 0; +} + +a { + color: var(--petrol); + text-underline-offset: 0.18em; +} + +:focus-visible { + outline: 2px solid var(--petrol); + outline-offset: 2px; + border-radius: 2px; +} + +.muted { + color: var(--ink-2); +} +.faint { + color: var(--ink-3); + font-size: 0.875rem; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font: inherit; + font-weight: 600; + font-size: 0.9375rem; + padding: 0.5rem 0.95rem; + border-radius: var(--radius-s); + border: 1px solid var(--rule-strong); + background: var(--sheet); + color: var(--ink); + cursor: pointer; + text-decoration: none; + line-height: 1.3; + white-space: nowrap; +} +.btn:hover { + border-color: var(--ink-3); +} +.btn.primary { + background: var(--petrol); + border-color: var(--petrol); + color: var(--petrol-ink); +} +.btn.primary:hover { + filter: brightness(1.08); +} +.btn.danger { + color: var(--block); +} +.btn.small { + font-size: 0.875rem; + padding: 0.3rem 0.65rem; +} +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +/* Forms */ +.field { + display: grid; + gap: 0.3rem; +} +.field > span { + font-weight: 600; + font-size: 0.9375rem; +} +.field small { + color: var(--ink-3); + font-size: 0.8125rem; +} +input[type='text'], +input[type='url'], +input[type='search'], +input[type='password'], +input[type='email'], +select, +textarea { + font: inherit; + color: var(--ink); + background: var(--sheet); + border: 1px solid var(--rule-strong); + border-radius: var(--radius-s); + padding: 0.5rem 0.65rem; + width: 100%; + min-width: 0; +} +textarea { + resize: vertical; + min-height: 5rem; +} +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--petrol); + outline-offset: 0; + border-color: var(--petrol); +} +.check { + display: flex; + gap: 0.55rem; + align-items: flex-start; +} +.check input { + margin-top: 0.3rem; + accent-color: var(--petrol); +} + +/* Surfaces */ +.sheet { + background: var(--sheet); + border: 1px solid var(--rule); + border-radius: var(--radius-m); + box-shadow: var(--shadow); +} + +.notice { + padding: 0.75rem 1rem; + border-radius: var(--radius-s); + border-left: 3px solid var(--petrol); + background: var(--petrol-soft); +} +.notice.error { + border-color: var(--block); + background: var(--block-soft); +} +.notice.ok { + border-color: var(--pass); + background: var(--pass-soft); +} + +/* Tables */ +table.list { + width: 100%; + border-collapse: collapse; + font-size: 0.9375rem; +} +table.list th { + text-align: left; + font-weight: 600; + color: var(--ink-2); + font-size: 0.8125rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--rule-strong); +} +table.list td { + padding: 0.65rem 0.75rem; + border-bottom: 1px solid var(--rule); + vertical-align: top; +} +table.list tr:last-child td { + border-bottom: 0; +} +.num { + text-align: right; + white-space: nowrap; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} + +/* Rendered Markdown (summaries, knowledge documents) */ +.prose { + display: grid; + gap: 0.75rem; + max-width: 52rem; + line-height: 1.6; +} +.prose > * { + margin: 0; +} +.prose h1, +.prose h2, +.prose h3, +.prose h4 { + margin-top: 0.75rem; +} +.prose h1 { + font-size: 1.375rem; +} +.prose h2 { + font-size: 1.1875rem; + padding-top: 0.75rem; + border-top: 1px solid var(--rule); +} +.prose h2:first-child { + border-top: 0; + padding-top: 0; +} +.prose ul, +.prose ol { + padding-left: 1.25rem; +} +.prose li + li { + margin-top: 0.2rem; +} +.prose code { + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 0.85em; + background: var(--sunken); + padding: 0.05em 0.3em; + border-radius: 3px; + word-break: break-word; +} +.prose pre { + background: var(--sunken); + padding: 0.75rem 1rem; + border-radius: var(--radius-s); + overflow-x: auto; +} +.prose pre code { + background: none; + padding: 0; +} +.prose table { + border-collapse: collapse; + font-size: 0.9375rem; + display: block; + overflow-x: auto; +} +.prose th, +.prose td { + border: 1px solid var(--rule); + padding: 0.4rem 0.6rem; + text-align: left; + vertical-align: top; +} +.prose th { + background: var(--sunken); +} + +/* A screenshot past its retention period */ +.shot-expired { + display: grid; + align-content: center; + gap: 0.2rem; + min-height: 6.5rem; + width: 11rem; + padding: 0.6rem; + border: 1px dashed var(--rule-strong); + border-radius: var(--radius-s); + background: var(--sunken); + color: var(--ink-2); + font-size: 0.8125rem; + text-align: center; +} diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts new file mode 100644 index 0000000..0f43d8a --- /dev/null +++ b/apps/web/src/app.d.ts @@ -0,0 +1,11 @@ +import type { SessionUser } from '#lib/server/auth.js'; + +declare global { + namespace App { + interface Locals { + user: SessionUser | null; + } + } +} + +export {}; diff --git a/apps/web/src/app.html b/apps/web/src/app.html new file mode 100644 index 0000000..8d9d4b2 --- /dev/null +++ b/apps/web/src/app.html @@ -0,0 +1,18 @@ + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts new file mode 100644 index 0000000..6b13bf0 --- /dev/null +++ b/apps/web/src/hooks.server.ts @@ -0,0 +1,23 @@ +import { redirect } from '@sveltejs/kit'; +import type { Handle } from '@sveltejs/kit/hooks'; +import { getSessionUser } from '@claudio/db'; +import { SESSION_COOKIE } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; + +// Webhooks authenticate with their HMAC signature instead of a session. +const PUBLIC_PATHS = ['/login', '/auth/', '/webhooks/', '/api/health']; + +export const handle: Handle = async ({ event, resolve }) => { + const token = event.cookies.get(SESSION_COOKIE); + const user = token ? await getSessionUser(db(), token) : null; + event.locals.user = user ? { id: user.id, login: user.login, name: user.name, avatarUrl: user.avatarUrl } : null; + + const path = event.url.pathname; + if (!event.locals.user && !PUBLIC_PATHS.some((p) => path === p || path.startsWith(p))) { + if (event.request.method !== 'GET' || path.startsWith('/runs/') || path.endsWith('/events') || path.endsWith('/live')) { + return new Response('Nicht angemeldet', { status: 401 }); + } + redirect(303, `/login?next=${encodeURIComponent(path + event.url.search)}`); + } + return resolve(event); +}; diff --git a/apps/web/src/lib/assets/favicon.svg b/apps/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..9aa2b52 --- /dev/null +++ b/apps/web/src/lib/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/lib/components/HumanTakeover.svelte b/apps/web/src/lib/components/HumanTakeover.svelte new file mode 100644 index 0000000..d91909f --- /dev/null +++ b/apps/web/src/lib/components/HumanTakeover.svelte @@ -0,0 +1,198 @@ + + +
+
+ +
+

Claudio braucht dich

+

{reason}. Löse es direkt im Bild unten, dann geht der Lauf weiter.

+
+
+ +
+ + {#if frames.url} + Browser von Claudio, zum Bedienen anklicken + {:else} +

Verbinde mit dem Browser …

+ {/if} + {#if lastClick}{/if} +
+ +
+
+ + + +
+
+ {#each ['Enter', 'Tab', 'Backspace', 'Escape'] as key (key)} + + {/each} + + +
+ {#if error}{/if} +
+ + + {url} +
+
+
+ + diff --git a/apps/web/src/lib/components/KnowledgeView.svelte b/apps/web/src/lib/components/KnowledgeView.svelte new file mode 100644 index 0000000..f5a4e59 --- /dev/null +++ b/apps/web/src/lib/components/KnowledgeView.svelte @@ -0,0 +1,60 @@ + + +{#if shown} +
+
+ {#each docs as doc (doc.name)} + + {/each} +
+
+ {@html shown.html} +
+
+{/if} + + diff --git a/apps/web/src/lib/components/LiveLog.svelte b/apps/web/src/lib/components/LiveLog.svelte new file mode 100644 index 0000000..16b3222 --- /dev/null +++ b/apps/web/src/lib/components/LiveLog.svelte @@ -0,0 +1,131 @@ + + +
+ +

Verlauf des Agenten

+ {log.length} Einträge{#if streaming}, live{/if} +
+
box && (follow = box.scrollTop + box.clientHeight >= box.scrollHeight - 24)}> + {#each log as entry (entry.seq)} +

{entry.line}

+ {:else} +

Noch keine Einträge.

+ {/each} +
+
+ + diff --git a/apps/web/src/lib/components/LiveView.svelte b/apps/web/src/lib/components/LiveView.svelte new file mode 100644 index 0000000..c154d8f --- /dev/null +++ b/apps/web/src/lib/components/LiveView.svelte @@ -0,0 +1,80 @@ + + +
+
+ +

Live-Ansicht

+ {url} +
+
+ {#if frames.url} + Aktueller Bildschirm des Browsers, den Claudio steuert + {:else} +

Wartet auf das erste Bild, sobald der Browser startet …

+ {/if} +
+
+ + diff --git a/apps/web/src/lib/components/OutcomeBadge.svelte b/apps/web/src/lib/components/OutcomeBadge.svelte new file mode 100644 index 0000000..27106d2 --- /dev/null +++ b/apps/web/src/lib/components/OutcomeBadge.svelte @@ -0,0 +1,55 @@ + + + + {#if live}{/if} + {label}{#if healed && !live} · repariert{/if} + + + diff --git a/apps/web/src/lib/components/ReviewTable.svelte b/apps/web/src/lib/components/ReviewTable.svelte new file mode 100644 index 0000000..4e00f87 --- /dev/null +++ b/apps/web/src/lib/components/ReviewTable.svelte @@ -0,0 +1,81 @@ + + +
+ + + + + + + + + + + + + + {#each reviews as r (r.id)} + + + + + + + + + + {/each} + +
Pull RequestErgebnisKriterienFindingsTestsKostenGestartet
+ + {#if showRepo}{r.repo}{/if} + #{r.prNumber} {r.prTitle ?? ''} + + {r.acTotal ? `${r.acPassed} / ${r.acTotal}` : '–'} + {#if r.status === 'completed' || r.findings > 0} + {r.findings}{#if r.blocking > 0}, {r.blocking} blockierend{/if} + {:else}–{/if} + {r.testsTotal ? `${r.testsPassed} / ${r.testsTotal}` : '–'}{formatUsd(r.costUsd)} + {formatDate(r.createdAt)} + von {r.startedBy} +
+
+ + diff --git a/apps/web/src/lib/components/Severity.svelte b/apps/web/src/lib/components/Severity.svelte new file mode 100644 index 0000000..1db6d6b --- /dev/null +++ b/apps/web/src/lib/components/Severity.svelte @@ -0,0 +1,35 @@ + + +{SEVERITY_LABEL[severity]} + + diff --git a/apps/web/src/lib/components/StatusBadge.svelte b/apps/web/src/lib/components/StatusBadge.svelte new file mode 100644 index 0000000..b04042d --- /dev/null +++ b/apps/web/src/lib/components/StatusBadge.svelte @@ -0,0 +1,65 @@ + + + + {#if tone === 'live'}{/if} + {label} + + + diff --git a/apps/web/src/lib/index.ts b/apps/web/src/lib/index.ts new file mode 100644 index 0000000..73c55ef --- /dev/null +++ b/apps/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `#lib` alias in this folder. diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts new file mode 100644 index 0000000..1583637 --- /dev/null +++ b/apps/web/src/lib/labels.ts @@ -0,0 +1,116 @@ +import type { Severity, Strictness, Verdict } from '@claudio/core'; + +export const STATUS_LABEL: Record = { + queued: 'Wartet', + running: 'Läuft', + completed: 'Abgeschlossen', + budget_exceeded: 'Budget erschöpft', + max_turns: 'Schrittlimit erreicht', + failed: 'Fehlgeschlagen', + aborted: 'Abgebrochen' +}; + +export const VERDICT_LABEL: Record = { + approve: 'Abnahme empfohlen', + request_changes: 'Änderungen nötig', + inconclusive: 'Kein Urteil' +}; + +export const SEVERITY_LABEL: Record = { + blocker: 'Blocker', + major: 'Major', + minor: 'Minor', + suggestion: 'Vorschlag' +}; + +export const CATEGORY_LABEL: Record = { + acceptance: 'Akzeptanzkriterium', + regression: 'Regression', + ux: 'UX', + ui: 'UI', + console: 'Konsole', + network: 'Netzwerk', + security: 'Sicherheit', + performance: 'Performance', + other: 'Sonstiges' +}; + +export const AC_LABEL: Record = { + passed: 'Erfüllt', + failed: 'Nicht erfüllt', + blocked: 'Blockiert', + not_testable: 'Nicht testbar' +}; + +export const STRICTNESS_LABEL: Record = { + lenient: 'Nachsichtig (nur Blocker blockieren)', + normal: 'Normal (ab Major)', + strict: 'Streng (ab Minor)' +}; + +export const BUDGET_LABEL: Record = { + quick: 'Schnell (bis 2 USD)', + standard: 'Standard (bis 8 USD)', + thorough: 'Gründlich (bis 25 USD)' +}; + +export const ANALYSIS_BUDGET_LABEL: Record = { + quick: 'Schnell (bis 4 USD)', + standard: 'Standard (bis 15 USD)', + thorough: 'Gründlich (bis 40 USD)' +}; + +export const VERBOSITY_LABEL: Record = { + tldr: 'Nur Kurzfassung', + normal: 'Normal', + detailed: 'Ausführlich' +}; + +const dateFmt = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); + +export function formatDate(d: Date | string | null | undefined): string { + if (!d) return '–'; + return dateFmt.format(typeof d === 'string' ? new Date(d) : d); +} + +export function formatUsd(v: string | number | null | undefined): string { + if (v === null || v === undefined || v === '') return '–'; + return `${Number(v).toFixed(2).replace('.', ',')} USD`; +} + +export function formatDuration(ms: number): string { + const s = Math.round(ms / 1000); + return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`; +} + +export const isActive = (status: string) => status === 'queued' || status === 'running'; + +export const OUTCOME_LABEL: Record = { + passed: 'Bestanden', + failed: 'Fehlgeschlagen', + inconclusive: 'Unklar' +}; + +export const MODE_LABEL: Record = { + record: 'Aufzeichnung', + replay: 'Wiedergabe', + explore: 'Erkundung' +}; + +export const DETERMINISM_LABEL: Record = { + deterministic: 'Deterministisch', + semi: 'Halb-deterministisch', + explorative: 'Explorativ' +}; + +export const DETERMINISM_HELP: Record = { + deterministic: 'Die aufgezeichneten Prüfungen entscheiden allein. Wiederholungen laufen ohne Modell und kosten nichts.', + semi: 'Die Schritte laufen ohne Modell, das Ergebnis bewertet danach ein Prüf-Agent.', + explorative: 'Ein Agent führt den Test jedes Mal neu aus, weil sich der Ablauf je nach Daten ändert.' +}; + +export const TEST_BUDGET_LABEL: Record = { + quick: 'Schnell (bis 1 USD)', + standard: 'Standard (bis 3 USD)', + thorough: 'Gründlich (bis 8 USD)' +}; diff --git a/apps/web/src/lib/live-frames.svelte.ts b/apps/web/src/lib/live-frames.svelte.ts new file mode 100644 index 0000000..f282cd7 --- /dev/null +++ b/apps/web/src/lib/live-frames.svelte.ts @@ -0,0 +1,33 @@ +/** + * Polls a run's live frame endpoint and swaps the image only once a frame has + * fully loaded, so the view never flickers. Stops when the component unmounts. + */ +export function liveFrames(src: () => string, intervalMs = 300) { + const state = $state({ url: null as string | null, failed: false }); + $effect(() => { + const base = src(); + let stopped = false; + let n = 0; + let timer: ReturnType | undefined; + const next = (delay: number) => (timer = setTimeout(load, delay)); + function load() { + if (stopped) return; + const img = new Image(); + img.onload = () => { + if (stopped) return; + state.url = img.src; + state.failed = false; + next(intervalMs); + }; + // 204 (no frame yet) and network errors both land here; retry a little slower. + img.onerror = () => next(1000); + img.src = `${base}?t=${Date.now()}-${n++}`; + } + load(); + return () => { + stopped = true; + clearTimeout(timer); + }; + }); + return state; +} diff --git a/apps/web/src/lib/markdown.ts b/apps/web/src/lib/markdown.ts new file mode 100644 index 0000000..d6fd4df --- /dev/null +++ b/apps/web/src/lib/markdown.ts @@ -0,0 +1,22 @@ +import { Marked } from 'marked'; + +const escape = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); + +/** Markdown from the model; raw HTML is shown as text, links open in a new tab. */ +const marked = new Marked({ + gfm: true, + breaks: false, + renderer: { + html: ({ text }) => escape(text), + link({ href, tokens }) { + const label = this.parser.parseInline(tokens); + if (!/^https?:\/\//i.test(href)) return label; + return `${label}`; + }, + image: ({ text }) => escape(text) + } +}); + +export function renderMarkdown(md: string): string { + return marked.parse(md, { async: false }); +} diff --git a/apps/web/src/lib/server/access.ts b/apps/web/src/lib/server/access.ts new file mode 100644 index 0000000..9e470ed --- /dev/null +++ b/apps/web/src/lib/server/access.ts @@ -0,0 +1,98 @@ +import { error } from '@sveltejs/kit'; +import { cachedAccess, getRepo, getRepoById, listRepos, storeAccess, type AccessLevel, type Repo } from '@claudio/db'; +import { createGithubClient } from '@claudio/github'; +import { userToken, type SessionUser } from './auth.js'; +import { db } from './db.js'; +import { DEMO_REPO } from './demo.js'; +import { devFixtures } from './env.js'; + +/** GitHub permissions are cached this long; changes on GitHub show up after that at the latest. */ +const TTL_MS = 15 * 60_000; +const RANK: Record = { none: 0, read: 1, write: 2, admin: 3 }; + +export const atLeast = (level: AccessLevel, min: AccessLevel) => RANK[level] >= RANK[min]; + +/** Maps GitHub's repository permissions onto Claudio's three levels. */ +function fromGithub(p: { admin?: boolean; maintain?: boolean; push?: boolean; pull?: boolean } | undefined): AccessLevel { + if (p?.admin) return 'admin'; + if (p?.maintain || p?.push) return 'write'; + if (p?.pull) return 'read'; + return 'none'; +} + +/** GitHub rejected the user's token (expired or revoked): they must sign in again. */ +export class GithubAuthExpired extends Error {} + +async function fetchLevel(token: string, fullName: string): Promise { + const [owner, repo] = fullName.split('/') as [string, string]; + try { + const { data } = await createGithubClient(token).rest.repos.get({ owner, repo }); + return fromGithub(data.permissions); + } catch (e) { + const status = (e as { status?: number }).status; + if (status === 401) throw new GithubAuthExpired(); + // 404/403: no access (GitHub hides private repos). Anything else: unknown, keep the cache. + return status === 404 || status === 403 ? 'none' : null; + } +} + +/** The user's level on each repository, from the cache or fresh from GitHub. */ +export async function accessMap(user: SessionUser, repos?: Repo[]): Promise & { authExpired?: boolean }> { + const all = repos ?? (await listRepos(db())); + const cache = await cachedAccess(db(), user.id); + const token = await userToken(user); + const out: Map & { authExpired?: boolean } = new Map(); + await Promise.all( + all.map(async (repo) => { + // The offline demo repo does not exist on GitHub; on the dev server everyone may use it. + if (repo.fullName === DEMO_REPO && devFixtures()) return out.set(repo.id, 'admin'); + const cached = cache.get(repo.id); + if (cached && Date.now() - cached.checkedAt.getTime() < TTL_MS) return out.set(repo.id, cached.level); + if (!token) return out.set(repo.id, 'none'); + let level: AccessLevel | null; + try { + level = await fetchLevel(token, repo.fullName); + } catch (e) { + if (!(e instanceof GithubAuthExpired)) throw e; + out.authExpired = true; + level = null; + } + if (level === null) return out.set(repo.id, cached?.level ?? 'none'); + await storeAccess(db(), user.id, repo.id, level); + out.set(repo.id, level); + }) + ); + return out; +} + +export async function accessTo(user: SessionUser, repo: Repo): Promise { + return (await accessMap(user, [repo])).get(repo.id) ?? 'none'; +} + +/** Repositories the user may see. */ +export async function visibleRepos(user: SessionUser): Promise<{ repos: Repo[]; levels: Map; authExpired: boolean }> { + const all = await listRepos(db()); + const levels = await accessMap(user, all); + return { repos: all.filter((r) => atLeast(levels.get(r.id) ?? 'none', 'read')), levels, authExpired: Boolean(levels.authExpired) }; +} + +const NEED: Record = { + none: '', + read: 'Lesezugriff', + write: 'Schreibrechte', + admin: 'Admin-Rechte' +}; + +/** + * Loads a repository and checks the user's level. Without read access the + * repository does not exist for them (404); with too little access, 403. + */ +export async function requireRepo(user: SessionUser | null, ref: number | string, min: AccessLevel = 'read'): Promise<{ repo: Repo; level: AccessLevel }> { + if (!user) error(401, 'Nicht angemeldet'); + const repo = typeof ref === 'number' ? await getRepoById(db(), ref) : await getRepo(db(), ref); + if (!repo) error(404, 'Nicht gefunden.'); + const level = await accessTo(user, repo); + if (!atLeast(level, 'read')) error(404, 'Nicht gefunden.'); + if (!atLeast(level, min)) error(403, `Dafür brauchst du ${NEED[min]} am Repository ${repo.fullName} auf GitHub.`); + return { repo, level }; +} diff --git a/apps/web/src/lib/server/anthropic.ts b/apps/web/src/lib/server/anthropic.ts new file mode 100644 index 0000000..b687d5d --- /dev/null +++ b/apps/web/src/lib/server/anthropic.ts @@ -0,0 +1,28 @@ +import type { Repo } from '@claudio/db'; +import { DEMO_REPO } from './demo.js'; +import { devFixtures } from './env.js'; + +export type KeyCheck = { ok: true } | { ok: false; reason: string; unverified?: boolean }; + +/** Asks Anthropic whether the key is valid (listing models costs nothing). */ +export async function checkAnthropicKey(key: string): Promise { + if (!/^sk-ant-[A-Za-z0-9_-]{20,}$/.test(key)) return { ok: false, reason: 'Das sieht nicht wie ein Anthropic-API-Key aus (beginnt mit sk-ant-).' }; + try { + const res = await fetch('https://api.anthropic.com/v1/models?limit=1', { + headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' }, + signal: AbortSignal.timeout(8000) + }); + if (res.ok) return { ok: true }; + if (res.status === 401 || res.status === 403) return { ok: false, reason: 'Anthropic hat den Key abgelehnt. Prüfe, ob er vollständig kopiert und nicht widerrufen ist.' }; + return { ok: false, reason: `Anthropic antwortete mit HTTP ${res.status}; der Key konnte nicht geprüft werden.`, unverified: true }; + } catch { + return { ok: false, reason: 'Anthropic war nicht erreichbar; der Key konnte nicht geprüft werden.', unverified: true }; + } +} + +/** The offline demo may run on the dev machine's own Claude login; real repos need their own key. */ +export function keyOptional(repo: Pick): boolean { + return devFixtures() && repo.fullName === DEMO_REPO; +} + +export const NO_KEY_MESSAGE = 'Für dieses Repository ist kein Anthropic-API-Key hinterlegt. Ein Admin kann ihn in den Einstellungen eintragen.'; diff --git a/apps/web/src/lib/server/auth.ts b/apps/web/src/lib/server/auth.ts new file mode 100644 index 0000000..0be7390 --- /dev/null +++ b/apps/web/src/lib/server/auth.ts @@ -0,0 +1,42 @@ +import type { Cookies } from '@sveltejs/kit'; +import { createSession, deleteSession, freshAccessToken, getUser, type User } from '@claudio/db'; +import { db } from './db.js'; +import { allowedOrgs, allowedUsers } from './env.js'; +import { fetchUserOrgs } from './github.js'; + +export const SESSION_COOKIE = 'claudio_session'; +export const STATE_COOKIE = 'claudio_oauth_state'; + +export type SessionUser = Pick; + +export async function startSession(cookies: Cookies, userId: number, secure: boolean): Promise { + const { token, expiresAt } = await createSession(db(), userId); + cookies.set(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure, expires: expiresAt }); +} + +export async function endSession(cookies: Cookies): Promise { + const token = cookies.get(SESSION_COOKIE); + if (token) await deleteSession(db(), token); + cookies.delete(SESSION_COOKIE, { path: '/' }); +} + +/** Applies CLAUDIO_ALLOWED_GITHUB_USERS / _ORGS. With neither set, every GitHub user may sign in. */ +export async function isAllowed(login: string, token: string): Promise { + const users = allowedUsers(); + const orgs = allowedOrgs(); + if (users.length === 0 && orgs.length === 0) return true; + if (users.includes(login.toLowerCase())) return true; + if (orgs.length === 0) return false; + const memberOf = await fetchUserOrgs(token); + return memberOf.some((o) => orgs.includes(o)); +} + +/** The signed-in user's GitHub token, or null for the offline demo user. */ +export async function userToken(user: SessionUser): Promise { + const row = await getUser(db(), user.id); + return row ? freshAccessToken(db(), row) : null; +} + +export function safeNext(next: string | null): string { + return next && next.startsWith('/') && !next.startsWith('//') ? next : '/'; +} diff --git a/apps/web/src/lib/server/automation.ts b/apps/web/src/lib/server/automation.ts new file mode 100644 index 0000000..81afd1e --- /dev/null +++ b/apps/web/src/lib/server/automation.ts @@ -0,0 +1,152 @@ +import { AnalysisRequestSchema, ReviewRequestSchema } from '@claudio/core'; +import { + createAnalysis, + createReview, + createTestRuns, + findActiveReview, + getUser, + listAnalyses, + listRoles, + listTestScripts, + freshAccessToken, + type AutoReviewDefaults, + type Repo +} from '@claudio/db'; +import { + createGithubClient, + decideTrigger, + getPullHead, + postPullRequestComment, + reactToComment, + renderInstanceUrl, + setCommitStatus, + type AutomationSettings +} from '@claudio/github'; +import { NO_KEY_MESSAGE, keyOptional } from './anthropic.js'; +import { db } from './db.js'; +import { DEMO_FIXTURE, DEMO_REPO } from './demo.js'; +import { devFixtures } from './env.js'; + +export const DEFAULT_AUTOMATION: AutomationSettings = { + instanceUrlTemplate: '', + onComment: true, + onLabel: true, + onPush: false, + analyzeOnMerge: false, + commitStatus: true +}; + +export const DEFAULT_AUTO_REVIEW: AutoReviewDefaults = { + roles: [], + budget: 'standard', + strictness: 'normal', + postComment: true, + runTests: true +}; + +export type EventResult = { action: 'review' | 'analysis' | 'ignored'; reason: string; id?: number }; + +/** Token of the person who set up the automation; automated runs act on their behalf. */ +async function automationToken(repo: Repo): Promise<{ token: string | null; userId: number }> { + const userId = repo.automationUserId ?? repo.createdBy; + const user = await getUser(db(), userId); + return { token: user ? await freshAccessToken(db(), user) : null, userId }; +} + +/** Turns a verified GitHub event into a review or analysis job. */ +export async function handleGithubEvent(repo: Repo, event: string, payload: Record, baseUrl: string): Promise { + const settings = repo.automation ?? DEFAULT_AUTOMATION; + const decision = decideTrigger(event, payload, settings, repo.defaultBranch); + if (decision.kind === 'ignore') return { action: 'ignored', reason: decision.reason }; + + const fixture = repo.fullName === DEMO_REPO && devFixtures() ? DEMO_FIXTURE : null; + const { token, userId } = await automationToken(repo); + const github = token && !fixture ? createGithubClient(token) : null; + + // Every model call of this repo runs on its own key; without one, nothing starts. + if (!repo.anthropicKeyEnc && !keyOptional(repo)) { + if (github && decision.kind === 'review' && decision.trigger === 'comment') { + await postPullRequestComment(github, repo.fullName, decision.prNumber, `Claudio kann diesen PR nicht prüfen: ${NO_KEY_MESSAGE}`).catch(() => undefined); + } + return { action: 'ignored', reason: 'no Anthropic API key for this repository' }; + } + + if (decision.kind === 'analysis') { + if (!repo.defaultInstanceUrl) return { action: 'ignored', reason: 'no default instance URL for the analysis' }; + if ((await listAnalyses(db(), { repoId: repo.id, activeOnly: true })).length > 0) return { action: 'ignored', reason: 'an analysis is already running' }; + const roles = (await listRoles(db(), repo.id)).map((r) => r.name); + const request = AnalysisRequestSchema.parse({ repo: repo.fullName, branch: decision.branch, instanceUrl: repo.defaultInstanceUrl, roles, budget: 'quick' }); + const id = await createAnalysis(db(), { repoId: repo.id, request, fixture, startedBy: userId }); + return { action: 'analysis', reason: `push to ${decision.branch}`, id }; + } + + // Review. Comment events carry no head commit; fetch it. + let { headSha, branch, title } = decision; + if ((!headSha || !branch) && github) { + const head = await getPullHead(github, repo.fullName, decision.prNumber); + if (!head.open) return { action: 'ignored', reason: 'pull request is closed' }; + ({ sha: headSha, branch, title } = head); + } + if (!repo.dataConfirmed) return { action: 'ignored', reason: 'the repository is not cleared for reviews (test data confirmation missing)' }; + + const template = settings.instanceUrlTemplate || (fixture ? repo.defaultInstanceUrl ?? '' : ''); + if (!template) { + if (github && decision.trigger === 'comment') { + await postPullRequestComment(github, repo.fullName, decision.prNumber, 'Claudio kann diesen PR nicht prüfen: In den Repo-Einstellungen fehlt die URL-Vorlage für die PR-Instanzen.').catch(() => undefined); + } + return { action: 'ignored', reason: 'no instance URL template' }; + } + const instanceUrl = renderInstanceUrl(template, { number: decision.prNumber, branch: branch ?? '', sha: headSha ?? '' }); + + if (await findActiveReview(db(), repo.id, decision.prNumber, decision.trigger === 'push' ? headSha : null)) { + return { action: 'ignored', reason: 'a review of this pull request is already queued or running' }; + } + + const defaults = repo.autoReview ?? DEFAULT_AUTO_REVIEW; + const roleNames = (await listRoles(db(), repo.id)).map((r) => r.name); + const parsed = ReviewRequestSchema.safeParse({ + repo: repo.fullName, + pr: decision.prNumber, + instanceUrl, + roles: defaults.roles.filter((r) => roleNames.includes(r)), + budget: decision.budget ?? defaults.budget, + strictness: defaults.strictness, + postComment: defaults.postComment && !fixture, + cloneRepo: !fixture + }); + if (!parsed.success) return { action: 'ignored', reason: `invalid review request: ${parsed.error.issues[0]?.message}` }; + + const id = await createReview(db(), { + repoId: repo.id, + prNumber: decision.prNumber, + prTitle: title, + request: parsed.data, + fixture, + startedBy: userId, + trigger: decision.trigger, + headSha + }); + + if (defaults.runTests) { + const tests = (await listTestScripts(db(), repo.id)).filter((t) => t.runInReviews); + await createTestRuns( + db(), + tests.map((t) => ({ + scriptId: t.id, + repoId: repo.id, + reviewId: id, + request: { repo: repo.fullName, instanceUrl, budget: 'quick' as const, language: parsed.data.language, headed: false, rerecord: false }, + startedBy: userId + })) + ); + } + + // Visible feedback on GitHub. Failures here must not lose the review. + if (github) { + if (decision.trigger === 'comment' && decision.commentId) await reactToComment(github, repo.fullName, decision.commentId).catch(() => undefined); + if (settings.commitStatus && headSha) { + await setCommitStatus(github, repo.fullName, headSha, 'pending', 'Claudio prüft den Pull Request …', `${baseUrl}/reviews/${id}`).catch(() => undefined); + } + } + return { action: 'review', reason: `${decision.trigger} on #${decision.prNumber}`, id }; +} diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts new file mode 100644 index 0000000..2603fab --- /dev/null +++ b/apps/web/src/lib/server/db.ts @@ -0,0 +1,4 @@ +import './env.js'; +import { getDb } from '@claudio/db'; + +export const db = () => getDb(); diff --git a/apps/web/src/lib/server/demo.ts b/apps/web/src/lib/server/demo.ts new file mode 100644 index 0000000..3175d99 --- /dev/null +++ b/apps/web/src/lib/server/demo.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { addRepo, saveRole, updateRepoSettings, type Db } from '@claudio/db'; +import type { PullRequestContext } from '@claudio/github'; +import { projectRoot } from './env.js'; + +/** The offline demo: apps/demo-target with its fixture PR. Only used with CLAUDIO_DEV_FIXTURES=1. */ +export const DEMO_REPO = 'vanderlars/dispo-demo'; +export const DEMO_FIXTURE = 'demo'; +export const DEMO_URL = 'http://127.0.0.1:4310'; + +export function demoPullRequest(): PullRequestContext { + return JSON.parse(readFileSync(resolve(projectRoot, 'apps/demo-target/fixtures/pr-context.json'), 'utf8')); +} + +export async function createDemoRepo(db: Db, userId: number): Promise { + const repo = await addRepo(db, { fullName: DEMO_REPO, isPrivate: false, defaultBranch: 'main', createdBy: userId }); + await updateRepoSettings(db, repo.id, { + notes: 'Demo-Dispositionsapp. Rollen: Disponent (legt Fahrten an), Admin. Datum im Format JJJJ-MM-TT.', + extraAllowedHosts: [], + dataConfirmed: true, + defaultInstanceUrl: DEMO_URL + }); + const base = { loginPath: '/login', successUrlContains: '/dashboard', recipe: null }; + await saveRole(db, repo.id, { ...base, name: 'dispatcher', description: 'Disponent', username: 'dispo@example.test', password: 'dispo' }); + await saveRole(db, repo.id, { ...base, name: 'admin', description: 'Administrator', username: 'admin@example.test', password: 'admin' }); +} diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts new file mode 100644 index 0000000..8a9a29c --- /dev/null +++ b/apps/web/src/lib/server/env.ts @@ -0,0 +1,38 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/** Monorepo root: apps/web is two levels down. Override with CLAUDIO_ROOT. */ +export const projectRoot = resolve(process.env.CLAUDIO_ROOT ?? resolve(process.cwd(), '../..')); + +// Web, worker and CLI share one .env at the repo root. Vite does not put it into process.env. +const envFile = resolve(projectRoot, '.env'); +if (existsSync(envFile)) process.loadEnvFile(envFile); + +export const runsRoot = resolve(projectRoot, process.env.CLAUDIO_RUNS_DIR ?? 'runs'); + +export function env(name: string): string | undefined { + const value = process.env[name]; + return value === undefined || value === '' ? undefined : value; +} + +export const devFixtures = () => process.env.CLAUDIO_DEV_FIXTURES === '1'; + +export function githubOAuthConfig(): { clientId: string; clientSecret: string } | null { + const clientId = env('GITHUB_CLIENT_ID'); + const clientSecret = env('GITHUB_CLIENT_SECRET'); + return clientId && clientSecret ? { clientId, clientSecret } : null; +} + +export function publicBaseUrl(fallback: URL): string { + return (env('PUBLIC_BASE_URL') ?? fallback.origin).replace(/\/$/, ''); +} + +function list(name: string): string[] { + return (env(name) ?? '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} + +export const allowedUsers = () => list('CLAUDIO_ALLOWED_GITHUB_USERS'); +export const allowedOrgs = () => list('CLAUDIO_ALLOWED_GITHUB_ORGS'); diff --git a/apps/web/src/lib/server/files.ts b/apps/web/src/lib/server/files.ts new file mode 100644 index 0000000..fdbd576 --- /dev/null +++ b/apps/web/src/lib/server/files.ts @@ -0,0 +1,33 @@ +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +/** + * Resolves a file inside one run directory. Returns null for anything that + * would leave it: "..", absolute paths, odd run ids. + */ +export function resolveRunFile(runsRoot: string, runId: string, file: string): string | null { + if (!/^[\w.-]+$/.test(runId) || runId === '.' || runId === '..') return null; + if (!file || file.includes('\0') || isAbsolute(file)) return null; + const runDir = resolve(runsRoot, runId); + const target = resolve(runDir, file); + const rel = relative(runDir, target); + if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('..')) return null; + return target; +} + +const TYPES: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + json: 'application/json; charset=utf-8', + md: 'text/markdown; charset=utf-8', + txt: 'text/plain; charset=utf-8', + log: 'text/plain; charset=utf-8', + yml: 'text/plain; charset=utf-8', + jsonl: 'text/plain; charset=utf-8', + zip: 'application/zip' +}; + +export function contentType(file: string): string { + return TYPES[file.split('.').pop()?.toLowerCase() ?? ''] ?? 'application/octet-stream'; +} diff --git a/apps/web/src/lib/server/github.ts b/apps/web/src/lib/server/github.ts new file mode 100644 index 0000000..a2cd4fc --- /dev/null +++ b/apps/web/src/lib/server/github.ts @@ -0,0 +1,103 @@ +import { Octokit } from 'octokit'; + +export type GithubRepo = { + fullName: string; + description: string | null; + isPrivate: boolean; + defaultBranch: string; + pushedAt: string | null; + owner: string; +}; + +export type GithubPull = { + number: number; + title: string; + author: string | null; + headRef: string; + draft: boolean; + updatedAt: string; + url: string; +}; + +export const octokit = (token: string) => new Octokit({ auth: token }); + +/** All repos the user can access (own, collaborator, org member), most recently pushed first. */ +export async function listUserRepos(token: string, max = 500): Promise { + const out: GithubRepo[] = []; + for await (const { data } of octokit(token).paginate.iterator(octokit(token).rest.repos.listForAuthenticatedUser, { + per_page: 100, + sort: 'pushed', + affiliation: 'owner,collaborator,organization_member' + })) { + for (const r of data) { + out.push({ + fullName: r.full_name, + description: r.description, + isPrivate: r.private, + defaultBranch: r.default_branch, + pushedAt: r.pushed_at ?? null, + owner: r.owner.login + }); + } + if (out.length >= max) break; + } + return out; +} + +export async function getRepoInfo(token: string, fullName: string) { + const [owner, repo] = fullName.split('/') as [string, string]; + const { data } = await octokit(token).rest.repos.get({ owner, repo }); + return { + fullName: data.full_name, + isPrivate: data.private, + defaultBranch: data.default_branch, + canWrite: Boolean(data.permissions?.push || data.permissions?.maintain || data.permissions?.admin) + }; +} + +export async function listOpenPulls(token: string, fullName: string): Promise { + const [owner, repo] = fullName.split('/') as [string, string]; + const { data } = await octokit(token).rest.pulls.list({ owner, repo, state: 'open', sort: 'updated', direction: 'desc', per_page: 50 }); + return data.map((p) => ({ + number: p.number, + title: p.title, + author: p.user?.login ?? null, + headRef: p.head.ref, + draft: Boolean(p.draft), + updatedAt: p.updated_at, + url: p.html_url + })); +} + +export async function getPull(token: string, fullName: string, number: number): Promise { + const [owner, repo] = fullName.split('/') as [string, string]; + const { data: p } = await octokit(token).rest.pulls.get({ owner, repo, pull_number: number }); + return { + number: p.number, + title: p.title, + author: p.user?.login ?? null, + headRef: p.head.ref, + draft: Boolean(p.draft), + updatedAt: p.updated_at, + url: p.html_url + }; +} + +export async function fetchGithubUser(token: string) { + const { data } = await octokit(token).rest.users.getAuthenticated(); + return { githubId: data.id, login: data.login, name: data.name ?? null, avatarUrl: data.avatar_url ?? null }; +} + +export async function fetchUserOrgs(token: string): Promise { + const orgs = await octokit(token).paginate(octokit(token).rest.orgs.listForAuthenticatedUser, { per_page: 100 }); + return orgs.map((o) => o.login.toLowerCase()); +} + +/** Human-readable message for GitHub API errors (expired token, missing access). */ +export function githubErrorMessage(e: unknown): string { + const status = (e as { status?: number }).status; + if (status === 401) return 'Dein GitHub-Zugang ist abgelaufen. Melde dich ab und wieder an.'; + if (status === 403) return 'GitHub verweigert den Zugriff (fehlende Rechte oder Rate-Limit).'; + if (status === 404) return 'GitHub kennt dieses Repository nicht oder du hast keinen Zugriff.'; + return `GitHub-Fehler: ${(e as Error).message}`; +} diff --git a/apps/web/src/lib/server/live.ts b/apps/web/src/lib/server/live.ts new file mode 100644 index 0000000..6badf9c --- /dev/null +++ b/apps/web/src/lib/server/live.ts @@ -0,0 +1,17 @@ +import { readFile } from 'node:fs/promises'; +import { runsRoot } from './env.js'; +import { resolveRunFile } from './files.js'; + +/** + * The newest frame of a run's browser (runDir/live.jpg, written by the worker's + * screencast). The page polls it a few times per second; 204 while there is none. + */ +export async function liveFrame(lookup: () => Promise<{ status: string; runId: string | null } | null>): Promise { + const run = await lookup(); + const file = run?.runId ? resolveRunFile(runsRoot, run.runId, 'live.jpg') : null; + const jpeg = file ? await readFile(file).catch(() => null) : null; + if (!jpeg || jpeg.length === 0) return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } }); + return new Response(new Uint8Array(jpeg), { + headers: { 'content-type': 'image/jpeg', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' } + }); +} diff --git a/apps/web/src/lib/server/run-files.ts b/apps/web/src/lib/server/run-files.ts new file mode 100644 index 0000000..6ecd0c6 --- /dev/null +++ b/apps/web/src/lib/server/run-files.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { EvidenceSchema, FindingSchema, type Evidence, type Finding } from '@claudio/core'; +import { artifactPaths } from '@claudio/db'; +import { z } from 'zod'; +import { db } from './db.js'; +import { runsRoot } from './env.js'; +import { resolveRunFile } from './files.js'; + +function readJson(runId: string, file: string, schema: z.ZodType): T[] { + const path = resolveRunFile(runsRoot, runId, file); + if (!path) return []; + try { + return z.array(schema).parse(JSON.parse(readFileSync(path, 'utf8'))); + } catch { + return []; + } +} + +/** URL under which the web app serves a file of a run. */ +export const runFileUrl = (runId: string, path: string) => `/runs/${runId}/${path.split('/').map(encodeURIComponent).join('/')}`; + +/** Fallback for analyses stored before their report carried evidence and findings. */ +export const runEvidenceFromDisk = (runId: string): Evidence[] => readJson(runId, 'evidence.json', EvidenceSchema); +export const runFindingsFromDisk = (runId: string): Finding[] => readJson(runId, 'findings.json', FindingSchema); + +/** + * Whether a run file can still be served: screenshots live in the database for the + * retention period; while a run is in progress (or locally) they are also on disk. + */ +export async function fileAvailability(runId: string | null | undefined): Promise<(path: string) => boolean> { + if (!runId) return () => false; + const stored = await artifactPaths(db(), runId); + return (path) => { + if (stored.has(path)) return true; + const file = resolveRunFile(runsRoot, runId, path); + return Boolean(file && existsSync(file)); + }; +} + +export type ShownEvidence = Evidence & { url: string | null; expired: boolean }; + +/** Evidence with a URL when the image is still available, otherwise marked as expired. */ +export async function withAvailability(runId: string | null | undefined, evidence: Evidence[]): Promise { + const available = await fileAvailability(runId); + return evidence.map((e) => { + const ok = Boolean(runId && e.path && available(e.path)); + return { ...e, url: ok ? runFileUrl(runId!, e.path!) : null, expired: Boolean(e.path) && !ok }; + }); +} diff --git a/apps/web/src/lib/server/sse.ts b/apps/web/src/lib/server/sse.ts new file mode 100644 index 0000000..d68ea5d --- /dev/null +++ b/apps/web/src/lib/server/sse.ts @@ -0,0 +1,55 @@ +import { eventsSince, type EventTarget } from '@claudio/db'; +import { db } from './db.js'; + +const ACTIVE = new Set(['queued', 'running']); + +/** + * Server-sent events for a run's live log: `line` per log entry, `status` on + * status changes, `done` once the run has finished. + */ +export function runEventStream(request: Request, url: URL, target: EventTarget, currentStatus: () => Promise): Response { + // On reconnect the browser sends Last-Event-ID; it is newer than the ?after of the original URL. + let after = Number(request.headers.get('last-event-id') ?? url.searchParams.get('after') ?? 0) || 0; + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (chunk: string) => controller.enqueue(encoder.encode(chunk)); + let closed = false; + let lastStatus = ''; + request.signal.addEventListener('abort', () => (closed = true)); + try { + while (!closed) { + const events = await eventsSince(db(), target, after); + for (const e of events) { + send(`id: ${e.seq}\nevent: line\ndata: ${JSON.stringify(e.line)}\n\n`); + after = e.seq; + } + if (events.length > 0) continue; + const status = (await currentStatus()) ?? 'failed'; + if (status !== lastStatus) { + lastStatus = status; + send(`event: status\ndata: ${JSON.stringify(status)}\n\n`); + } + if (!ACTIVE.has(status)) { + send(`event: done\ndata: ${JSON.stringify(status)}\n\n`); + break; + } + send(': keep-alive\n\n'); + await new Promise((r) => setTimeout(r, 1000)); + } + } catch (e) { + if (!closed) send(`event: failure\ndata: ${JSON.stringify((e as Error).message)}\n\n`); + } + try { + controller.close(); + } catch { + // client already gone + } + } + }); + + return new Response(stream, { + headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' } + }); +} diff --git a/apps/web/src/lib/server/summary.ts b/apps/web/src/lib/server/summary.ts new file mode 100644 index 0000000..6872ed4 --- /dev/null +++ b/apps/web/src/lib/server/summary.ts @@ -0,0 +1,30 @@ +import { blockingFindings, verdict, withTests, type ReviewTestResult, type Strictness } from '@claudio/core'; +import type { ReviewListItem } from '@claudio/db'; +import type { ReviewSummary } from '#lib/types.js'; + +export type { ReviewSummary }; + + +/** List-row view of a review. The verdict honours dismissed findings and the chosen strictness. */ +export function summarize(review: ReviewListItem, dismissed: string[] = [], strictness?: Strictness, tests: ReviewTestResult[] = []): ReviewSummary { + const report = review.report; + const s = strictness ?? report?.request.strictness; + return { + id: review.id, + repo: review.repoFullName, + prNumber: review.prNumber, + prTitle: review.prTitle, + status: review.status, + verdict: report ? withTests(verdict(report, s, dismissed), tests) : null, + testsPassed: tests.filter((t) => t.outcome === 'passed').length, + testsTotal: tests.length, + acPassed: report?.acceptanceCriteria.filter((a) => a.status === 'passed').length ?? 0, + acTotal: report?.acceptanceCriteria.length ?? 0, + findings: report ? report.findings.length - report.findings.filter((f) => dismissed.includes(f.id)).length : 0, + blocking: report ? blockingFindings(report, s, dismissed).length : 0, + costUsd: review.costUsd, + startedBy: review.startedByLogin, + createdAt: review.createdAt, + finishedAt: review.finishedAt + }; +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts new file mode 100644 index 0000000..1d02156 --- /dev/null +++ b/apps/web/src/lib/types.ts @@ -0,0 +1,20 @@ +import type { Verdict } from '@claudio/core'; + +export type ReviewSummary = { + id: number; + repo: string; + prNumber: number; + prTitle: string | null; + status: string; + verdict: Verdict | null; + acPassed: number; + acTotal: number; + findings: number; + testsPassed: number; + testsTotal: number; + blocking: number; + costUsd: string | null; + startedBy: string; + createdAt: Date; + finishedAt: Date | null; +}; diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte new file mode 100644 index 0000000..48e6501 --- /dev/null +++ b/apps/web/src/routes/+error.svelte @@ -0,0 +1,21 @@ + + +{page.status} · Claudio + +
+

{page.status === 404 ? 'Nicht gefunden' : 'Etwas ist schiefgelaufen'}

+

{page.error?.message}

+ Zur Übersicht +
+ + diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts new file mode 100644 index 0000000..58c1264 --- /dev/null +++ b/apps/web/src/routes/+layout.server.ts @@ -0,0 +1,22 @@ +import { listOpenHumanRequests } from '@claudio/db'; +import { visibleRepos } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { DEMO_REPO } from '#lib/server/demo.js'; +import { devFixtures } from '#lib/server/env.js'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + if (!locals.user) return { user: null, repos: [], waiting: [], authExpired: false }; + const [{ repos, authExpired }, allWaiting] = await Promise.all([visibleRepos(locals.user), listOpenHumanRequests(db())]); + const visible = new Set(repos.map((r) => r.id)); + const waiting = allWaiting.filter((w) => visible.has(w.repoId)); + const runPath = { review: 'reviews', analysis: 'analyses', test: 'test-runs' } as const; + return { + user: locals.user, + authExpired, + // onGithub: false for the offline demo repo, which does not exist on GitHub. + repos: repos.map((r) => ({ fullName: r.fullName, onGithub: !(devFixtures() && r.fullName === DEMO_REPO) })), + // Runs paused for a person (CAPTCHA); shown on every page so nobody misses them. + waiting: waiting.map((w) => ({ id: w.id, reason: w.reason, repo: w.repoFullName, href: `/${runPath[w.runKind]}/${w.runRefId}` })) + }; +}; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte new file mode 100644 index 0000000..b94011e --- /dev/null +++ b/apps/web/src/routes/+layout.svelte @@ -0,0 +1,320 @@ + + + + + Claudio + + +{#if data.user} +
+
+ + + Claudio + + + {#if repos.length} +
    + {#each repos as repo (repo.fullName)} + {@const [owner, name] = repo.fullName.split('/')} +
  • + + {owner}/{name} + +
  • + {/each} +
+ {/if} + Repository hinzufügen +
+ {#if data.user.avatarUrl} + + {/if} + {data.user.login} +
+ +
+
+
+
+ {#if data.authExpired} + + {/if} + {#each (data.waiting ?? []).filter((w) => w.href !== page.url.pathname) as w (w.id)} + + + Claudio wartet auf dich: {w.reason} ({w.repo}) + Öffnen + + {/each} + {#if currentRepo} + {@const info = repos.find((r) => r.fullName === currentRepo)} + {@const onRepoPage = Boolean(page.params.owner)} + + {#if !onRepoPage || info?.onGithub} +
+ {#if !onRepoPage}{currentRepo}{/if} + {#if info?.onGithub} + Auf GitHub + {/if} +
+ {/if} + {/if} + {@render children()} +
+
+{:else} + {@render children()} +{/if} + + diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts new file mode 100644 index 0000000..a3eb51e --- /dev/null +++ b/apps/web/src/routes/+page.server.ts @@ -0,0 +1,23 @@ +import { dismissedFindingIds, listAnalyses, listReviews, testResultsByReview } from '@claudio/db'; +import { visibleRepos } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { summarize } from '#lib/server/summary.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + const { repos } = await visibleRepos(locals.user!); + const repoIds = repos.map((r) => r.id); + const [reviews, analyses] = await Promise.all([ + listReviews(db(), { limit: 40, repoIds }), + listAnalyses(db(), { activeOnly: true, repoIds }) + ]); + const [dismissed, tests] = await Promise.all([ + dismissedFindingIds(db(), reviews.map((r) => r.id)), + testResultsByReview(db(), reviews.map((r) => r.id)) + ]); + return { + reviews: reviews.map((r) => summarize(r, dismissed.get(r.id), undefined, tests.get(r.id))), + repoCount: repos.length, + analyses: analyses.map((a) => ({ id: a.id, repo: a.repoFullName, status: a.status })) + }; +}; diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte new file mode 100644 index 0000000..b0b0a8d --- /dev/null +++ b/apps/web/src/routes/+page.svelte @@ -0,0 +1,74 @@ + + +Übersicht · Claudio + +
+
+

Reviews

+

+ {#if running > 0} + {running === 1 ? 'Ein Review läuft gerade.' : `${running} Reviews laufen gerade.`} + {:else} + Alle Abnahmen, die Claudio durchgeführt hat, die neuesten zuerst. + {/if} +

+
+ Review starten +
+ +{#each data.analyses as a (a.id)} +

+ Analyse von {a.repo} {a.status === 'queued' ? 'wartet auf den Worker' : 'läuft'}. Verlauf ansehen +

+{/each} + +{#if data.reviews.length > 0} +
+ +
+{:else} +
+

Noch keine Reviews

+ {#if data.repoCount === 0} +

Füge zuerst ein Repository hinzu. Danach kannst du für jeden offenen Pull Request einen Review starten.

+ Repository hinzufügen + {:else} +

Wähle ein Repository und einen offenen Pull Request, um den ersten Review zu starten.

+ Repository wählen + {/if} +
+{/if} + + diff --git a/apps/web/src/routes/analyses/[id]/+page.server.ts b/apps/web/src/routes/analyses/[id]/+page.server.ts new file mode 100644 index 0000000..dfd8cd5 --- /dev/null +++ b/apps/web/src/routes/analyses/[id]/+page.server.ts @@ -0,0 +1,57 @@ +import { error } from '@sveltejs/kit'; +import { KNOWLEDGE_DOCS, sortBySeverity } from '@claudio/core'; +import { eventsSince, getAnalysis, knowledgeDocsOf, openHumanRequest, requestAnalysisAbort } from '@claudio/db'; +import type { AccessLevel } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { runEvidenceFromDisk, runFindingsFromDisk, withAvailability } from '#lib/server/run-files.js'; +import { renderMarkdown } from '#lib/markdown.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadAnalysis(idParam: string, user: App.Locals['user'], min: AccessLevel = 'read') { + const id = Number(idParam); + const analysis = Number.isInteger(id) ? await getAnalysis(db(), id) : undefined; + if (!analysis) error(404, 'Diese Analyse gibt es nicht.'); + // Visible only with access to its repository on GitHub. + await requireRepo(user, analysis.repoId, min); + return analysis; +} + +export const load: PageServerLoad = async ({ params, locals }) => { + const a = await loadAnalysis(params.id, locals.user, 'read'); + const [events, docs] = await Promise.all([eventsSince(db(), { analysisId: a.id }, 0, 5000), knowledgeDocsOf(db(), a.id)]); + return { + analysis: { + id: a.id, + repo: a.repoFullName, + status: a.status, + instanceUrl: a.instanceUrl, + request: a.request, + branch: a.branch, + commitSha: a.commitSha, + previousCommitSha: a.report?.previousCommitSha ?? null, + summaryHtml: a.report?.summary ? renderMarkdown(a.report.summary) : '', + cost: a.report?.cost ?? null, + errors: a.report?.errors ?? (a.error ? [a.error] : []), + startedBy: a.startedByLogin, + createdAt: a.createdAt, + finishedAt: a.finishedAt, + abortRequested: a.abortRequested, + fixture: a.fixture + }, + docs: docs.map((d) => ({ name: d.name, title: KNOWLEDGE_DOCS[d.name].title, html: renderMarkdown(d.content), chars: d.content.length })), + // Older analyses kept these only in the run directory. + evidence: (await withAvailability(a.runId, a.report?.evidence ?? (a.runId ? runEvidenceFromDisk(a.runId) : []))).filter((e) => e.kind === 'screenshot'), + findings: sortBySeverity(a.report?.findings ?? (a.runId ? runFindingsFromDisk(a.runId) : [])), + events: events.map((e) => ({ seq: e.seq, line: e.line })), + human: await openHumanRequest(db(), 'analysis', a.id).then((h) => (h ? { id: h.id, reason: h.reason } : null)) + }; +}; + +export const actions: Actions = { + abort: async ({ params, locals }) => { + const a = await loadAnalysis(params.id, locals.user, 'write'); + await requestAnalysisAbort(db(), a.id); + return { aborted: true }; + } +}; diff --git a/apps/web/src/routes/analyses/[id]/+page.svelte b/apps/web/src/routes/analyses/[id]/+page.svelte new file mode 100644 index 0000000..2216b3a --- /dev/null +++ b/apps/web/src/routes/analyses/[id]/+page.svelte @@ -0,0 +1,285 @@ + + +Analyse {a.repo} · Claudio + +

Wissen

+
+
+

{isUpdate ? 'Wissensbasis aktualisieren' : 'Repository analysieren'}

+

+ {#if a.commitSha} + Commit {a.commitSha.slice(0, 7)}{a.branch ? ` auf ${a.branch}` : ''}{#if isUpdate}, aufbauend auf {a.previousCommitSha?.slice(0, 7)}{/if} + {:else} + Instanz {a.instanceUrl} + {/if} +

+
+ {#if active} +
+ +
+ {/if} +
+ +
+
+ {#if active} +

+ {a.status === 'queued' + ? 'Wartet auf den Worker. Läuft kein Worker, starte ihn mit pnpm worker.' + : 'Claudio liest den Code und geht die App Bildschirm für Bildschirm durch. Die Dokumente erscheinen, sobald die Analyse fertig ist.'} +

+ {/if} + + {#if active && data.human} + + {:else if active} + + {/if} + + {#if isActive(data.analysis.status) || data.events.length > 0} + (liveStatus = s)} + online={(line) => /^Mensch (gebraucht|fertig):/.test(line) && invalidateAll()} + /> + {/if} + + {#if a.summaryHtml} +
+

Zusammenfassung

+
{@html a.summaryHtml}
+
+ {/if} + + {#if data.docs.length} +
+

Wissensbasis aus diesem Lauf

+ +
+ {:else if !active} +

Diese Analyse hat keine Dokumente geschrieben.

+ {/if} + + {#if data.findings.length} +
+

Nebenbei gefundene Fehler

+

Bei der Analyse aufgefallen. Sie sind kein Ergebnis eines Reviews und blockieren nichts.

+
    + {#each data.findings as f (f.id)} +
  • + +
    + {f.title} + {CATEGORY_LABEL[f.category] ?? f.category}{f.url ? `, ${f.url}` : ''} +

    {f.description}

    +
    +
  • + {/each} +
+
+ {/if} + + {#if data.evidence.length} +
+

Screenshots

+
+ {#each data.evidence as ev (ev.id)} + {#if ev.url} + + {ev.label} + {ev.id} {ev.label} + + {:else} +
+ Screenshot gelöscht + {ev.id} {ev.label} +
+ {/if} + {/each} +
+
+ {/if} + + {#if a.errors.length} +
+

Probleme während des Runs

+
    + {#each a.errors as err, i (i)}
  • {err}
  • {/each} +
+
+ {/if} +
+ + +
+ + diff --git a/apps/web/src/routes/analyses/[id]/events/+server.ts b/apps/web/src/routes/analyses/[id]/events/+server.ts new file mode 100644 index 0000000..0aa91c1 --- /dev/null +++ b/apps/web/src/routes/analyses/[id]/events/+server.ts @@ -0,0 +1,14 @@ +import { error } from '@sveltejs/kit'; +import { getAnalysis } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { runEventStream } from '#lib/server/sse.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, request, locals }) => { + const id = Number(params.id); + const item = Number.isInteger(id) ? await getAnalysis(db(), id) : undefined; + if (!item) error(404, 'Diese Analyse gibt es nicht.'); + await requireRepo(locals.user, item.repoId, 'read'); + return runEventStream(request, url, { analysisId: id }, async () => (await getAnalysis(db(), id))?.status ?? null); +}; diff --git a/apps/web/src/routes/analyses/[id]/live/+server.ts b/apps/web/src/routes/analyses/[id]/live/+server.ts new file mode 100644 index 0000000..9784bdb --- /dev/null +++ b/apps/web/src/routes/analyses/[id]/live/+server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { getAnalysis } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { liveFrame } from '#lib/server/live.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, locals }) => { + const id = Number(params.id); + const item = Number.isInteger(id) ? await getAnalysis(db(), id) : undefined; + if (!item) error(404, 'Diese Analyse gibt es nicht.'); + await requireRepo(locals.user, item.repoId, 'read'); + return liveFrame(async () => { + const a = await getAnalysis(db(), id); + return a ? { status: a.status, runId: a.runId } : null; + }); +}; diff --git a/apps/web/src/routes/api/health/+server.ts b/apps/web/src/routes/api/health/+server.ts new file mode 100644 index 0000000..546f07d --- /dev/null +++ b/apps/web/src/routes/api/health/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import { sql } from 'drizzle-orm'; +import { db } from '#lib/server/db.js'; +import type { RequestHandler } from './$types'; + +/** Liveness/readiness for Kubernetes: the app answers and reaches the database. Public, no data. */ +export const GET: RequestHandler = async () => { + try { + await db().execute(sql`SELECT 1`); + return json({ ok: true }); + } catch (err) { + return json({ ok: false, error: err instanceof Error ? err.message : String(err) }, { status: 503 }); + } +}; diff --git a/apps/web/src/routes/auth/dev/+server.ts b/apps/web/src/routes/auth/dev/+server.ts new file mode 100644 index 0000000..4d18584 --- /dev/null +++ b/apps/web/src/routes/auth/dev/+server.ts @@ -0,0 +1,15 @@ +import { error, redirect } from '@sveltejs/kit'; +import { dev } from '$app/env'; +import { upsertUser } from '@claudio/db'; +import { startSession } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { devFixtures } from '#lib/server/env.js'; +import type { RequestHandler } from './$types'; + +/** Offline demo login without GitHub. Only on the dev server with CLAUDIO_DEV_FIXTURES=1. */ +export const POST: RequestHandler = async ({ cookies }) => { + if (!dev || !devFixtures()) error(404, 'Nicht gefunden'); + const user = await upsertUser(db(), { githubId: 0, login: 'demo', name: 'Demo (ohne GitHub)', avatarUrl: null, accessToken: '' }); + await startSession(cookies, user.id, false); + redirect(303, '/'); +}; diff --git a/apps/web/src/routes/auth/github/+server.ts b/apps/web/src/routes/auth/github/+server.ts new file mode 100644 index 0000000..60edc4f --- /dev/null +++ b/apps/web/src/routes/auth/github/+server.ts @@ -0,0 +1,25 @@ +import { randomBytes } from 'node:crypto'; +import { redirect } from '@sveltejs/kit'; +import { STATE_COOKIE, safeNext } from '#lib/server/auth.js'; +import { githubOAuthConfig, publicBaseUrl } from '#lib/server/env.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = ({ url, cookies }) => { + const config = githubOAuthConfig(); + if (!config) redirect(303, '/login?error=config'); + const state = randomBytes(16).toString('base64url'); + const next = safeNext(url.searchParams.get('next')); + cookies.set(STATE_COOKIE, `${state}|${next}`, { + path: '/auth', + httpOnly: true, + sameSite: 'lax', + secure: url.protocol === 'https:', + maxAge: 600 + }); + const authorize = new URL('https://github.com/login/oauth/authorize'); + authorize.searchParams.set('client_id', config.clientId); + authorize.searchParams.set('redirect_uri', `${publicBaseUrl(url)}/auth/github/callback`); + authorize.searchParams.set('scope', 'repo read:user read:org'); + authorize.searchParams.set('state', state); + redirect(303, authorize.toString(), { external: true }); +}; diff --git a/apps/web/src/routes/auth/github/callback/+server.ts b/apps/web/src/routes/auth/github/callback/+server.ts new file mode 100644 index 0000000..1b38b39 --- /dev/null +++ b/apps/web/src/routes/auth/github/callback/+server.ts @@ -0,0 +1,38 @@ +import { redirect } from '@sveltejs/kit'; +import { clearAccess, upsertUser } from '@claudio/db'; +import { STATE_COOKIE, isAllowed, safeNext, startSession } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { githubOAuthConfig, publicBaseUrl } from '#lib/server/env.js'; +import { exchangeCode } from '@claudio/github'; +import { fetchGithubUser } from '#lib/server/github.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const config = githubOAuthConfig(); + if (!config) redirect(303, '/login?error=config'); + + const [expected, next] = (cookies.get(STATE_COOKIE) ?? '').split('|'); + cookies.delete(STATE_COOKIE, { path: '/auth' }); + const code = url.searchParams.get('code'); + if (url.searchParams.get('error') === 'access_denied') redirect(303, '/login?error=denied'); + if (!code || !expected || url.searchParams.get('state') !== expected) redirect(303, '/login?error=state'); + + let target = '/login?error=github'; + try { + const tokens = await exchangeCode(config.clientId, config.clientSecret, code, `${publicBaseUrl(url)}/auth/github/callback`); + const token = tokens.accessToken; + const profile = await fetchGithubUser(token); + if (!(await isAllowed(profile.login, token))) { + target = '/login?error=forbidden'; + } else { + const user = await upsertUser(db(), { ...profile, accessToken: token, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt }); + // Permissions are fetched fresh after every login, so changes on GitHub apply right away. + await clearAccess(db(), user.id); + await startSession(cookies, user.id, url.protocol === 'https:'); + target = safeNext(next ?? null); + } + } catch (e) { + console.error('GitHub login failed', e); + } + redirect(303, target); +}; diff --git a/apps/web/src/routes/auth/logout/+server.ts b/apps/web/src/routes/auth/logout/+server.ts new file mode 100644 index 0000000..6803125 --- /dev/null +++ b/apps/web/src/routes/auth/logout/+server.ts @@ -0,0 +1,8 @@ +import { redirect } from '@sveltejs/kit'; +import { endSession } from '#lib/server/auth.js'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ cookies }) => { + await endSession(cookies); + redirect(303, '/login'); +}; diff --git a/apps/web/src/routes/human/[id]/input/+server.ts b/apps/web/src/routes/human/[id]/input/+server.ts new file mode 100644 index 0000000..885beb5 --- /dev/null +++ b/apps/web/src/routes/human/[id]/input/+server.ts @@ -0,0 +1,25 @@ +import { error, json } from '@sveltejs/kit'; +import { z } from 'zod'; +import { addHumanInput, getHumanRequest } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import type { RequestHandler } from './$types'; + +const InputSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('click'), x: z.number().min(0).max(1), y: z.number().min(0).max(1) }), + z.object({ type: z.literal('type'), text: z.string().min(1).max(500) }), + z.object({ type: z.literal('key'), key: z.enum(['Enter', 'Tab', 'Backspace', 'Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space']) }), + z.object({ type: z.literal('scroll'), dy: z.number().min(-2000).max(2000) }) +]); + +/** One click or keystroke from the live view; the worker replays it in the run's browser. */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const req = await getHumanRequest(db(), Number(params.id)); + if (!req) error(404, 'Unbekannte Anfrage.'); + await requireRepo(locals.user, req.repoId, 'write'); + if (req.status !== 'open') error(409, 'Die Anfrage ist bereits erledigt.'); + const parsed = InputSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) error(400, 'Ungültige Eingabe.'); + await addHumanInput(db(), req.id, parsed.data, locals.user!.id); + return json({ ok: true }); +}; diff --git a/apps/web/src/routes/human/[id]/resolve/+server.ts b/apps/web/src/routes/human/[id]/resolve/+server.ts new file mode 100644 index 0000000..a6b6118 --- /dev/null +++ b/apps/web/src/routes/human/[id]/resolve/+server.ts @@ -0,0 +1,16 @@ +import { error, json } from '@sveltejs/kit'; +import { closeHumanRequest, getHumanRequest } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import type { RequestHandler } from './$types'; + +/** The person is done (resolved) or gives up (cancelled); the worker continues the run either way. */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const req = await getHumanRequest(db(), Number(params.id)); + if (!req) error(404, 'Unbekannte Anfrage.'); + await requireRepo(locals.user, req.repoId, 'write'); + const body = (await request.json().catch(() => ({}))) as { status?: string }; + const status = body.status === 'cancelled' ? 'cancelled' : 'resolved'; + await closeHumanRequest(db(), req.id, status, locals.user!.id); + return json({ ok: true, status }); +}; diff --git a/apps/web/src/routes/kosten/+page.server.ts b/apps/web/src/routes/kosten/+page.server.ts new file mode 100644 index 0000000..e128b2d --- /dev/null +++ b/apps/web/src/routes/kosten/+page.server.ts @@ -0,0 +1,33 @@ +import { costSummary } from '@claudio/db'; +import { visibleRepos } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import type { PageServerLoad } from './$types'; + +type Cell = { runs: number; usd: number }; +type RepoRow = { repo: string; review: Cell; analysis: Cell; test: Cell; total: number }; + +export const load: PageServerLoad = async ({ locals }) => { + const visible = new Set((await visibleRepos(locals.user!)).repos.map((r) => r.fullName)); + const since = new Date(); + since.setMonth(since.getMonth() - 11, 1); + since.setHours(0, 0, 0, 0); + const rows = (await costSummary(db(), since)).filter((r) => visible.has(r.repo)); + + const months = new Map>(); + for (const r of rows) { + const repos = months.get(r.month) ?? new Map(); + const empty = (): Cell => ({ runs: 0, usd: 0 }); + const row = repos.get(r.repo) ?? { repo: r.repo, review: empty(), analysis: empty(), test: empty(), total: 0 }; + row[r.kind] = { runs: r.runs, usd: r.usd }; + row.total += r.usd; + repos.set(r.repo, row); + months.set(r.month, repos); + } + const result = [...months.entries()] + .sort(([a], [b]) => b.localeCompare(a)) + .map(([month, repos]) => { + const list = [...repos.values()].sort((a, b) => b.total - a.total); + return { month, repos: list, total: list.reduce((n, r) => n + r.total, 0), runs: list.reduce((n, r) => n + r.review.runs + r.analysis.runs + r.test.runs, 0) }; + }); + return { months: result, yearTotal: result.reduce((n, m) => n + m.total, 0) }; +}; diff --git a/apps/web/src/routes/kosten/+page.svelte b/apps/web/src/routes/kosten/+page.svelte new file mode 100644 index 0000000..d2f658e --- /dev/null +++ b/apps/web/src/routes/kosten/+page.svelte @@ -0,0 +1,82 @@ + + +Kosten · Claudio + +

Kosten

+

+ Modellkosten der letzten zwölf Monate, wie das Agent SDK sie meldet: Schätzungen zum Listenpreis, keine Rechnung. Reine Testwiedergaben ohne Modell kosten nichts. +

+ +{#if data.months.length === 0} +

Noch keine Läufe.

+{:else} +

Zwölf Monate gesamt: {formatUsd(data.yearTotal)}

+ {#each data.months as m (m.month)} +
+

{monthName(m.month)} {formatUsd(m.total)} {m.runs} Läufe

+
+ + + + + + {#each m.repos as r (r.repo)} + + + + + + + + {/each} + +
RepositoryReviewsAnalysenTestsSumme
{r.repo}{cell(r.review)}{r.review.runs || ''}{cell(r.analysis)}{r.analysis.runs || ''}{cell(r.test)}{r.test.runs || ''}{formatUsd(r.total)}
+
+
+ {/each} +

Die kleine Zahl neben einem Betrag ist die Anzahl der Läufe.

+{/if} + + diff --git a/apps/web/src/routes/login/+page.server.ts b/apps/web/src/routes/login/+page.server.ts new file mode 100644 index 0000000..e8814a1 --- /dev/null +++ b/apps/web/src/routes/login/+page.server.ts @@ -0,0 +1,25 @@ +import { redirect } from '@sveltejs/kit'; +import { dev } from '$app/env'; +import { safeNext } from '#lib/server/auth.js'; +import { devFixtures, githubOAuthConfig } from '#lib/server/env.js'; +import type { PageServerLoad } from './$types'; + +const ERRORS: Record = { + config: 'Der GitHub-Login ist noch nicht eingerichtet. Trage GITHUB_CLIENT_ID und GITHUB_CLIENT_SECRET in die .env ein und starte Claudio neu.', + state: 'Die Anmeldung ist abgelaufen oder wurde unterbrochen. Starte sie bitte noch einmal.', + denied: 'Du hast den Zugriff bei GitHub abgelehnt. Ohne Freigabe kann Claudio deine Repositories nicht laden.', + forbidden: 'Dein GitHub-Konto ist für Claudio nicht freigeschaltet. Wende dich an die Person, die Claudio betreibt.', + github: 'GitHub hat die Anmeldung nicht bestätigt. Versuche es erneut.' +}; + +export const load: PageServerLoad = ({ locals, url }) => { + const next = safeNext(url.searchParams.get('next')); + if (locals.user) redirect(303, next); + const code = url.searchParams.get('error'); + return { + next, + error: code ? (ERRORS[code] ?? ERRORS.github) : null, + configured: githubOAuthConfig() !== null, + devLogin: dev && devFixtures() + }; +}; diff --git a/apps/web/src/routes/login/+page.svelte b/apps/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..2a31c24 --- /dev/null +++ b/apps/web/src/routes/login/+page.svelte @@ -0,0 +1,99 @@ + + +Anmelden · Claudio + +
+
+
+ + Claudio +
+

Pull Requests abnehmen lassen, bevor sie jemand von Hand prüft.

+

+ Claudio liest PR und Ticket, testet die laufende Instanz im Browser und schreibt ein Prüfprotokoll mit Belegen. +

+ + {#if data.error} + + {/if} + + + + Mit GitHub anmelden + + {#if !data.configured} +

+ GitHub-Login ist noch nicht eingerichtet: Lege eine OAuth App an und trage Client-ID und Secret in die + .env ein (siehe README). +

+ {/if} + + {#if data.devLogin} +
+ +

Nur auf dem Entwicklungsserver mit CLAUDIO_DEV_FIXTURES=1.

+
+ {/if} +
+
+ + diff --git a/apps/web/src/routes/repos/+page.server.ts b/apps/web/src/routes/repos/+page.server.ts new file mode 100644 index 0000000..5fe28c7 --- /dev/null +++ b/apps/web/src/routes/repos/+page.server.ts @@ -0,0 +1,73 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { addRepo, listReviews } from '@claudio/db'; +import { visibleRepos } from '#lib/server/access.js'; +import { userToken } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { createDemoRepo, DEMO_REPO } from '#lib/server/demo.js'; +import { devFixtures, githubOAuthConfig } from '#lib/server/env.js'; +import { getRepoInfo, githubErrorMessage, listUserRepos, type GithubRepo } from '#lib/server/github.js'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + const { repos } = await visibleRepos(locals.user!); + const reviews = await listReviews(db(), { limit: 500, repoIds: repos.map((r) => r.id) }); + const lastReview = new Map(); + for (const r of reviews) if (!lastReview.has(r.repoId)) lastReview.set(r.repoId, r.createdAt); + + const token = await userToken(locals.user!); + let github: GithubRepo[] = []; + let githubError: string | null = null; + if (token) { + try { + github = await listUserRepos(token); + } catch (e) { + githubError = githubErrorMessage(e); + } + } else { + githubError = 'Du bist ohne GitHub angemeldet. Melde dich mit GitHub an, um deine Repositories zu laden.'; + } + + const added = new Set(repos.map((r) => r.fullName)); + return { + repos: repos.map((r) => ({ + fullName: r.fullName, + isPrivate: r.isPrivate, + dataConfirmed: r.dataConfirmed, + lastReview: lastReview.get(r.id) ?? null + })), + github: github.map((g) => ({ ...g, added: added.has(g.fullName) })), + githubError, + demoAvailable: devFixtures() && !added.has(DEMO_REPO), + // Organisations with OAuth access restrictions must grant Claudio access before their repos show up. + grantUrl: githubOAuthConfig() ? `https://github.com/settings/connections/applications/${githubOAuthConfig()!.clientId}` : null, + hasToken: Boolean(token) + }; +}; + +export const actions: Actions = { + add: async ({ request, locals }) => { + // Accepts owner/name or a GitHub URL. + const fullName = String((await request.formData()).get('fullName') ?? '') + .trim() + .replace(/^https?:\/\/github\.com\//i, '') + .replace(/\.git$/i, '') + .replace(/\/+$/, ''); + if (!/^[\w.-]+\/[\w.-]+$/.test(fullName)) return fail(400, { error: 'Gib das Repository als besitzer/name an.' }); + const token = await userToken(locals.user!); + if (!token) return fail(400, { error: 'Zum Hinzufügen brauchst du eine Anmeldung mit GitHub.' }); + let info; + try { + info = await getRepoInfo(token, fullName); + } catch (e) { + return fail(400, { error: githubErrorMessage(e) }); + } + if (!info.canWrite) return fail(403, { error: 'Zum Einrichten in Claudio brauchst du Schreibrechte an diesem Repository auf GitHub.' }); + await addRepo(db(), { fullName: info.fullName, isPrivate: info.isPrivate, defaultBranch: info.defaultBranch, createdBy: locals.user!.id }); + redirect(303, `/repos/${info.fullName}?tab=settings&new=1`); + }, + demo: async ({ locals }) => { + if (!devFixtures()) return fail(404, { error: 'Die Demo ist nicht aktiviert.' }); + await createDemoRepo(db(), locals.user!.id); + redirect(303, `/repos/${DEMO_REPO}`); + } +}; diff --git a/apps/web/src/routes/repos/+page.svelte b/apps/web/src/routes/repos/+page.svelte new file mode 100644 index 0000000..0d4ce20 --- /dev/null +++ b/apps/web/src/routes/repos/+page.svelte @@ -0,0 +1,183 @@ + + +Repositories · Claudio + +

Repositories

+

Claudio prüft Pull Requests der Repositories, die hier eingerichtet sind.

+ +{#if form?.error} + +{/if} + +
+

Eingerichtet

+ {#if data.repos.length > 0} +
    + {#each data.repos as repo (repo.fullName)} +
  • + {repo.fullName} + + {repo.lastReview ? `Letzter Review ${formatDate(repo.lastReview)}` : 'Noch kein Review'} + + {#if !repo.dataConfirmed} + Einrichtung abschließen + {/if} +
  • + {/each} +
+ {:else} +

Noch keins. Wähle unten ein Repository aus deinem GitHub-Konto.

+ {/if} + {#if data.demoAvailable} +
+ + Die Dispo-Demo aus apps/demo-target mit fertigem Beispiel-PR, ganz ohne GitHub. +
+ {/if} +
+ +
+
+

Aus GitHub hinzufügen

+ {#if data.github.length > 0} + + {/if} +
+ + {#if data.githubError} +

{data.githubError}

+ {:else if filtered.length === 0} +

Kein Repository passt zu „{query}“.

+ {:else} +
    + {#each filtered as repo (repo.fullName)} +
  • +
    + {repo.fullName} + {#if repo.isPrivate}privat{/if} + {#if repo.description}

    {repo.description}

    {/if} +
    + {#if repo.added} + Öffnen + {:else} +
    + + +
    + {/if} +
  • + {/each} +
+ {#if data.github.length > filtered.length && !query} +

Die {filtered.length} zuletzt geänderten von {data.github.length} Repositories. Suche, um weitere zu finden.

+ {/if} + {/if} + + {#if data.hasToken} +
+ + +
+ {/if} +
+ + diff --git a/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts b/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts new file mode 100644 index 0000000..0e5416d --- /dev/null +++ b/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts @@ -0,0 +1,421 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { z } from 'zod'; +import { randomUUID } from 'node:crypto'; +import { AnalysisRequestSchema, KNOWLEDGE_DOCS, LoginStepSchema, TestRunRequestSchema, parseHostList } from '@claudio/core'; +import { + type AccessLevel, + createAnalysis, + createTestRuns, + createTestScript, + deleteRole, + dismissedFindingIds, + getUser, + latestKnowledge, + listAnalyses, + listReviews, + listRoles, + listTestRuns, + listTestScripts, + removeLesson, + removeRepo, + repoAnthropicKey, + setAnthropicKey, + roleUsername, + saveRole, + setWebhook, + testResultsByReview, + updateAutomation, + updateRepoSettings +} from '@claudio/db'; +import { atLeast, requireRepo, visibleRepos } from '#lib/server/access.js'; +import { NO_KEY_MESSAGE, checkAnthropicKey, keyOptional } from '#lib/server/anthropic.js'; +import { userToken } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { DEMO_FIXTURE, DEMO_REPO, demoPullRequest } from '#lib/server/demo.js'; +import { createGithubClient, createRepoWebhook, deleteRepoWebhook, getBranchHead, getRepoWebhook } from '@claudio/github'; +import { DEFAULT_AUTOMATION, DEFAULT_AUTO_REVIEW } from '#lib/server/automation.js'; +import { publicBaseUrl } from '#lib/server/env.js'; +import { renderMarkdown } from '#lib/markdown.js'; +import { devFixtures } from '#lib/server/env.js'; +import { githubErrorMessage, listOpenPulls, type GithubPull } from '#lib/server/github.js'; +import { summarize } from '#lib/server/summary.js'; +import type { Actions, PageServerLoad } from './$types'; + +/** The repo of this page, if the user has at least `min` access on GitHub (404 / 403 otherwise). */ +async function loadRepo(params: { owner: string; name: string }, user: App.Locals['user'], min: AccessLevel = 'read') { + return (await requireRepo(user, `${params.owner}/${params.name}`, min)).repo; +} + +export const load: PageServerLoad = async ({ params, locals, url }) => { + const { repo, level } = await requireRepo(locals.user, `${params.owner}/${params.name}`, 'read'); + const [roles, reviews] = await Promise.all([listRoles(db(), repo.id), listReviews(db(), { repoId: repo.id, limit: 100 })]); + const [dismissed, reviewTests] = await Promise.all([ + dismissedFindingIds(db(), reviews.map((r) => r.id)), + testResultsByReview(db(), reviews.map((r) => r.id)) + ]); + + let pulls: (GithubPull & { fixture?: boolean })[] = []; + let pullsError: string | null = null; + if (repo.fullName === DEMO_REPO && devFixtures()) { + const pr = demoPullRequest(); + pulls = [ + { number: pr.number, title: pr.title, author: pr.author ?? null, headRef: pr.headRef, draft: false, updatedAt: new Date().toISOString(), url: pr.url, fixture: true } + ]; + } else { + const token = await userToken(locals.user!); + if (!token) pullsError = 'Ohne GitHub-Anmeldung kann Claudio keine Pull Requests laden.'; + else + try { + pulls = await listOpenPulls(token, repo.fullName); + } catch (e) { + pullsError = githubErrorMessage(e); + } + } + + const isDemo = repo.fullName === DEMO_REPO && devFixtures(); + const [knowledge, analyses] = await Promise.all([latestKnowledge(db(), repo.id), listAnalyses(db(), { repoId: repo.id, limit: 20 })]); + + // Is the knowledge base older than the head of the analysed branch? + let headSha: string | null = null; + if (knowledge && !isDemo && knowledge.branch) { + const token = await userToken(locals.user!); + if (token) headSha = await getBranchHead(createGithubClient(token), repo.fullName, knowledge.branch).catch(() => null); + } + + const batchId = url.searchParams.get('batch'); + const [tests, activeTestRuns, batchRuns] = await Promise.all([ + listTestScripts(db(), repo.id), + listTestRuns(db(), { repoId: repo.id, activeOnly: true }, 100), + batchId ? listTestRuns(db(), { batchId, repoId: repo.id }, 200) : Promise.resolve([]) + ]); + + // Webhook health, shown in the settings. + let webhook: { id: number; ok: boolean | null; status: string; by: string | null } | null = null; + if (repo.webhookId) { + const owner = repo.automationUserId ? await getUser(db(), repo.automationUserId) : undefined; + const token = await userToken(locals.user!); + const info = token && !isDemo ? await getRepoWebhook(createGithubClient(token), repo.fullName, repo.webhookId).catch(() => null) : null; + const code = info?.lastResponse?.code ?? null; + webhook = { + id: repo.webhookId, + ok: code === null ? null : code >= 200 && code < 300, + status: info ? (code ? `Letzte Zustellung: HTTP ${code}${info.lastResponse?.message ? ` (${info.lastResponse.message})` : ''}` : 'Noch keine Zustellung') : 'Status nicht abrufbar', + by: owner?.login ?? null + }; + } + const base = publicBaseUrl(url); + + // Anthropic key: status, and other repos (where this user is admin) whose key can be copied over. + const keySetBy = repo.anthropicKeySetBy ? await getUser(db(), repo.anthropicKeySetBy) : undefined; + let keySources: { fullName: string; hint: string }[] = []; + if (level === 'admin') { + const { repos: visible, levels } = await visibleRepos(locals.user!); + keySources = visible + .filter((r) => r.id !== repo.id && r.anthropicKeyEnc && atLeast(levels.get(r.id) ?? 'none', 'admin')) + .map((r) => ({ fullName: r.fullName, hint: r.anthropicKeyHint ?? '' })); + } + + const tab = url.searchParams.get('tab'); + return { + repo: { + fullName: repo.fullName, + isPrivate: repo.isPrivate, + notes: repo.notes ?? '', + extraAllowedHosts: repo.extraAllowedHosts, + dataConfirmed: repo.dataConfirmed, + defaultInstanceUrl: repo.defaultInstanceUrl ?? '', + defaultBranch: repo.defaultBranch + }, + roles: roles.map((r) => ({ + name: r.name, + description: r.description ?? '', + username: roleUsername(r), + loginPath: r.loginPath, + successUrlContains: r.successUrlContains ?? '', + recipe: r.recipe ? JSON.stringify(r.recipe, null, 2) : '' + })), + reviews: reviews.map((r) => summarize(r, dismissed.get(r.id), undefined, reviewTests.get(r.id))), + pulls, + pullsError, + knowledge: knowledge + ? { + analysisId: knowledge.analysisId, + commitSha: knowledge.commitSha, + branch: knowledge.branch, + analyzedAt: knowledge.analyzedAt, + startedBy: knowledge.startedByLogin, + complete: knowledge.status === 'completed', + stale: headSha !== null && headSha !== knowledge.commitSha, + headSha, + docs: knowledge.docs.map((d) => ({ name: d.name, title: KNOWLEDGE_DOCS[d.name].title, html: renderMarkdown(d.content), chars: d.content.length })) + } + : null, + analyses: analyses.map((a) => ({ + id: a.id, + status: a.status, + commitSha: a.commitSha, + costUsd: a.costUsd, + docCount: a.docCount, + startedBy: a.startedByLogin, + createdAt: a.createdAt + })), + anthropicKey: repo.anthropicKeyEnc + ? { hint: repo.anthropicKeyHint ?? '', by: keySetBy?.login ?? null, at: repo.anthropicKeySetAt } + : null, + keyOptional: keyOptional(repo), + keySources, + automation: repo.automation ?? DEFAULT_AUTOMATION, + autoReview: repo.autoReview ?? DEFAULT_AUTO_REVIEW, + webhook, + webhookUrl: `${base}/webhooks/github`, + localBaseUrl: /^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\])/.test(base), + lessons: repo.lessons ?? [], + tests: tests.map((t) => ({ + id: t.id, + name: t.name, + determinism: t.determinism, + stepCount: t.steps?.length ?? 0, + runInReviews: t.runInReviews, + lastOutcome: t.lastOutcome, + lastStatus: t.lastStatus, + lastRunAt: t.lastRunAt, + lastHealed: t.lastHealed + })), + activeTestRuns: activeTestRuns.length, + batch: batchId + ? batchRuns.map((r) => ({ id: r.id, scriptName: r.scriptName, status: r.status, outcome: r.outcome, healed: r.healed, mode: r.mode, costUsd: r.costUsd })) + : null, + level, + tab: tab === 'settings' || tab === 'reviews' || tab === 'knowledge' || tab === 'tests' ? tab : 'pulls', + isNew: url.searchParams.has('new') + }; +}; + +const text = (v: FormDataEntryValue | null) => String(v ?? '').trim(); + +const RoleForm = z.object({ + name: z.string().regex(/^[a-z][a-z0-9_-]{0,39}$/, 'Rollenname: Kleinbuchstaben, Ziffern, - oder _, beginnt mit einem Buchstaben.'), + description: z.string().max(300), + username: z.string().max(300), + password: z.string().max(300), + loginPath: z.string().regex(/^\//, 'Der Login-Pfad beginnt mit /.'), + successUrlContains: z.string().max(300), + recipe: z.string() +}); + +export const actions: Actions = { + settings: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + const f = await request.formData(); + const defaultInstanceUrl = text(f.get('defaultInstanceUrl')); + if (defaultInstanceUrl && !URL.canParse(defaultInstanceUrl)) return fail(400, { error: 'Die Standard-URL ist keine gültige Adresse.', form: 'settings' }); + const hosts = parseHostList(text(f.get('extraAllowedHosts'))); + await updateRepoSettings(db(), repo.id, { + notes: text(f.get('notes')) || null, + extraAllowedHosts: hosts, + dataConfirmed: f.get('dataConfirmed') === 'on', + defaultInstanceUrl: defaultInstanceUrl || null + }); + return { saved: 'settings' }; + }, + + role: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + const f = await request.formData(); + const parsed = RoleForm.safeParse(Object.fromEntries(['name', 'description', 'username', 'password', 'loginPath', 'successUrlContains', 'recipe'].map((k) => [k, text(f.get(k))]))); + if (!parsed.success) return fail(400, { error: parsed.error.issues[0]?.message ?? 'Eingaben prüfen.', form: 'role' }); + const v = parsed.data; + let recipe = null; + if (v.recipe) { + try { + recipe = z.array(LoginStepSchema).parse(JSON.parse(v.recipe)); + } catch { + return fail(400, { error: 'Das Login-Rezept ist kein gültiges JSON-Array aus Login-Schritten.', form: 'role' }); + } + } + try { + await saveRole(db(), repo.id, { + name: v.name, + description: v.description || null, + username: v.username || undefined, + password: v.password || undefined, + loginPath: v.loginPath, + successUrlContains: v.successUrlContains || null, + recipe + }); + } catch (e) { + return fail(400, { error: (e as Error).message, form: 'role' }); + } + return { saved: `role:${v.name}` }; + }, + + deleteRole: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + await deleteRole(db(), repo.id, text((await request.formData()).get('name'))); + return { saved: 'deleted' }; + }, + + analyze: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'write'); + const roles = await listRoles(db(), repo.id); + if (!repo.dataConfirmed || roles.length === 0) return fail(400, { error: 'Richte zuerst Rollen und die Datenschutz-Bestätigung ein.', form: 'analyze' }); + if (!repo.anthropicKeyEnc && !keyOptional(repo)) return fail(400, { error: NO_KEY_MESSAGE, form: 'analyze' }); + if ((await listAnalyses(db(), { repoId: repo.id, activeOnly: true })).length > 0) { + return fail(400, { error: 'Für dieses Repository läuft schon eine Analyse.', form: 'analyze' }); + } + const f = await request.formData(); + const fixture = repo.fullName === DEMO_REPO && devFixtures() ? DEMO_FIXTURE : null; + const parsed = AnalysisRequestSchema.safeParse({ + repo: repo.fullName, + instanceUrl: text(f.get('instanceUrl')), + roles: f.getAll('roles').map(String), + budget: f.get('budget'), + language: 'de', + extraInstructions: text(f.get('extraInstructions')) || undefined + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + return fail(400, { error: issue?.path[0] === 'instanceUrl' ? 'Gib die vollständige Adresse einer laufenden Instanz an.' : (issue?.message ?? 'Eingaben prüfen.'), form: 'analyze' }); + } + if (!fixture && !(await userToken(locals.user!))) return fail(400, { error: 'Zum Lesen des Codes brauchst du eine Anmeldung mit GitHub.', form: 'analyze' }); + const id = await createAnalysis(db(), { repoId: repo.id, request: parsed.data, fixture, startedBy: locals.user!.id }); + redirect(303, `/analyses/${id}`); + }, + + createTest: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'write'); + const f = await request.formData(); + const input = { + name: text(f.get('name')), + description: text(f.get('description')), + expectation: text(f.get('expectation')), + roles: f.getAll('roles').map(String), + runInReviews: f.get('runInReviews') === 'on', + allowedHosts: parseHostList(text(f.get('allowedHosts'))), + allowAnyHost: f.get('allowAnyHost') === 'on' + }; + if (!input.name || !input.description || !input.expectation) { + return fail(400, { error: 'Name, Ablauf und erwartetes Ergebnis sind Pflicht.', form: 'createTest' }); + } + const id = await createTestScript(db(), repo.id, input, locals.user!.id); + redirect(303, `/tests/${id}?new=1`); + }, + + runTests: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'write'); + if (!repo.dataConfirmed) return fail(400, { error: 'Bestätige zuerst in den Einstellungen, dass die Instanz nur Testdaten enthält.', form: 'runTests' }); + const f = await request.formData(); + const parsed = TestRunRequestSchema.safeParse({ repo: repo.fullName, instanceUrl: text(f.get('instanceUrl')), budget: f.get('budget') }); + if (!parsed.success) return fail(400, { error: 'Gib die vollständige Adresse einer laufenden Instanz an.', form: 'runTests' }); + const scripts = await listTestScripts(db(), repo.id); + if (scripts.length === 0) return fail(400, { error: 'Es gibt noch keine Tests.', form: 'runTests' }); + const batchId = randomUUID().slice(0, 8); + await createTestRuns( + db(), + scripts.map((s) => ({ scriptId: s.id, repoId: repo.id, request: parsed.data, batchId, startedBy: locals.user!.id })) + ); + redirect(303, `/repos/${repo.fullName}?tab=tests&batch=${batchId}`); + }, + + automation: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + const f = await request.formData(); + const template = text(f.get('instanceUrlTemplate')); + if (template && !URL.canParse(template.replace(/\{[a-z_]+\}/g, 'x'))) { + return fail(400, { error: 'Die URL-Vorlage ergibt keine gültige Adresse.', form: 'automation' }); + } + const pick = (v: FormDataEntryValue | null, allowed: readonly T[], fallback: T): T => + (allowed as readonly string[]).includes(String(v)) ? (String(v) as T) : fallback; + await updateAutomation( + db(), + repo.id, + { + instanceUrlTemplate: template, + onComment: f.get('onComment') === 'on', + onLabel: f.get('onLabel') === 'on', + onPush: f.get('onPush') === 'on', + analyzeOnMerge: f.get('analyzeOnMerge') === 'on', + commitStatus: f.get('commitStatus') === 'on' + }, + { + roles: f.getAll('autoRoles').map(String), + budget: pick(f.get('autoBudget'), ['quick', 'standard', 'thorough'] as const, 'standard'), + strictness: pick(f.get('autoStrictness'), ['lenient', 'normal', 'strict'] as const, 'normal'), + postComment: f.get('autoComment') === 'on', + runTests: f.get('autoTests') === 'on' + } + ); + return { saved: 'automation' }; + }, + + installWebhook: async ({ params, locals, url }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + const token = await userToken(locals.user!); + if (!token) return fail(400, { error: 'Zum Einrichten brauchst du eine Anmeldung mit GitHub.', form: 'webhook' }); + const secret = randomUUID() + randomUUID(); + const github = createGithubClient(token); + try { + if (repo.webhookId) await deleteRepoWebhook(github, repo.fullName, repo.webhookId); + const id = await createRepoWebhook(github, repo.fullName, `${publicBaseUrl(url)}/webhooks/github`, secret); + await setWebhook(db(), repo.id, { webhookId: id, secret, userId: locals.user!.id }); + } catch (e) { + const status = (e as { status?: number }).status; + return fail(400, { + error: status === 404 || status === 403 ? 'GitHub erlaubt das nicht: Für Webhooks brauchst du Admin-Rechte am Repository.' : `GitHub hat abgelehnt: ${(e as Error).message}`, + form: 'webhook' + }); + } + return { saved: 'webhook' }; + }, + + removeWebhook: async ({ params, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + const token = await userToken(locals.user!); + if (repo.webhookId && token) await deleteRepoWebhook(createGithubClient(token), repo.fullName, repo.webhookId).catch(() => undefined); + await setWebhook(db(), repo.id, null); + return { saved: 'webhook-removed' }; + }, + + setKey: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + const f = await request.formData(); + const source = text(f.get('source')); + let key = text(f.get('key')); + if (source) { + // Copy from another repository, only if the user is admin there too. + const { repo: from } = await requireRepo(locals.user, source, 'admin'); + key = repoAnthropicKey(from) ?? ''; + if (!key) return fail(400, { error: `In ${from.fullName} ist kein Key hinterlegt.`, form: 'key' }); + } + if (!key) return fail(400, { error: 'Trage einen Key ein oder wähle ein Repository, aus dem er übernommen wird.', form: 'key' }); + const check = await checkAnthropicKey(key); + if (!check.ok && !(check.unverified && f.get('force') === 'on')) { + return fail(400, { error: check.reason, unverified: check.unverified ?? false, form: 'key' }); + } + await setAnthropicKey(db(), repo.id, key, locals.user!.id); + return { saved: 'key' }; + }, + + removeKey: async ({ params, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + await setAnthropicKey(db(), repo.id, null, locals.user!.id); + return { saved: 'key-removed' }; + }, + + removeLesson: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + const f = await request.formData(); + await removeLesson(db(), repo.id, { reviewId: Number(f.get('reviewId')), findingId: text(f.get('findingId')) }); + return { saved: 'lesson' }; + }, + + remove: async ({ params, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + const [reviews, analyses] = await Promise.all([listReviews(db(), { repoId: repo.id, limit: 1 }), listAnalyses(db(), { repoId: repo.id, limit: 1 })]); + if (reviews.length > 0 || analyses.length > 0) { + return fail(400, { error: 'Repositories mit Reviews oder Analysen bleiben erhalten, damit Prüfprotokolle und Wissensbasis nicht verloren gehen.', form: 'remove' }); + } + await removeRepo(db(), repo.id); + redirect(303, '/repos'); + } +}; diff --git a/apps/web/src/routes/repos/[owner]/[name]/+page.svelte b/apps/web/src/routes/repos/[owner]/[name]/+page.svelte new file mode 100644 index 0000000..9a26b13 --- /dev/null +++ b/apps/web/src/routes/repos/[owner]/[name]/+page.svelte @@ -0,0 +1,954 @@ + + +{data.repo.fullName} · Claudio + +

{data.repo.fullName}

+ + + +{#if data.tab === 'pulls'} + {#if !canWrite} +

Du hast an diesem Repository auf GitHub nur Lesezugriff. Berichte, Tests und Wissen kannst du ansehen, aber nichts starten.

+ {:else if !ready} +

+ Bevor Claudio Pull Requests prüfen kann, fehlt noch: + {[!hasKey && 'ein Anthropic-API-Key', !data.repo.dataConfirmed && 'die Bestätigung, dass die Instanzen nur Testdaten enthalten', data.roles.length === 0 && 'mindestens eine Rolle mit Zugangsdaten'].filter(Boolean).join(', ')}. + {#if isAdmin}Einstellungen öffnen{:else}Das kann ein Admin des Repositorys in den Einstellungen eintragen.{/if} +

+ {/if} + {#if data.pullsError} +

{data.pullsError}

+ {:else if data.pulls.length === 0} +

Keine offenen Pull Requests.

+ {:else} +
    + {#each data.pulls as pr (pr.number)} +
  • +
    + #{pr.number} {pr.title} + + {pr.headRef}{pr.author ? `, von ${pr.author}` : ''}, aktualisiert {formatDate(pr.updatedAt)} + {#if pr.draft}Entwurf{/if} + {#if pr.fixture}Demo-Fixture{/if} + +
    + {#if ready && canWrite} + Review starten + {/if} +
  • + {/each} +
+ {/if} +{:else if data.tab === 'tests'} + {#if data.batch} + {@const done = data.batch.filter((r) => !isActive(r.status)).length} +
+

Suite-Lauf

+

+ {done} von {data.batch.length} Tests fertig{#if done === data.batch.length}: + {data.batch.filter((r) => r.outcome === 'passed').length} bestanden, + {data.batch.filter((r) => r.outcome === 'failed').length} fehlgeschlagen{/if}. +

+
+ + + + {#each data.batch as run (run.id)} + + + + + + + {/each} + +
TestErgebnisArtKosten
{run.scriptName}{run.mode ? MODE_LABEL[run.mode] : '–'}{formatUsd(run.costUsd)}
+
+
+ {/if} + + {#if data.tests.length} +
+ + + + + + {#each data.tests as test (test.id)} + + + + + + + + {/each} + +
TestArtSchritteLetzter LaufBei Reviews
{test.name}{test.determinism ? DETERMINISM_LABEL[test.determinism] : 'Noch nicht aufgezeichnet'}{test.stepCount || '–'} + {#if test.lastStatus} + + {formatDate(test.lastRunAt)} + {:else}–{/if} + {test.runInReviews ? 'Ja' : 'Nein'}
+
+ {:else} +
+

Noch keine Tests

+

+ Beschreibe einen Ablauf und was am Ende stimmen muss. Beim ersten Lauf führt Claudio ihn im Browser aus und zeichnet die Schritte + auf. Danach laufen sie ohne Modell in Sekunden, auch automatisch bei jedem PR-Review. +

+
+ {/if} + + {#if data.tests.length && !data.activeTestRuns && canWrite} +
+ Alle Tests ausführen +
(runningTests = true)}> + {#if formError('runTests')}{/if} + + +
+ +
+
+
+ {:else if data.activeTestRuns} +

{data.activeTestRuns} Testläufe warten oder laufen gerade.

+ {/if} + + {#if canWrite} +
+ Neuer Test +
+ {#if formError('createTest')}{/if} + + + +
+ Rollen +
+ {#each data.roles as role (role.name)} + + {/each} +
+
+
+ Andere Websites + + +
+ +
+
+
+ {/if} +{:else if data.tab === 'knowledge'} + {#if runningAnalysis} +

+ Eine Analyse {runningAnalysis.status === 'queued' ? 'wartet auf den Worker' : 'läuft gerade'}. + Verlauf ansehen +

+ {/if} + + {#if data.knowledge} +
+

+ Stand: Commit {data.knowledge.commitSha.slice(0, 7)}{data.knowledge.branch ? ` auf ${data.knowledge.branch}` : ''}, + analysiert {formatDate(data.knowledge.analyzedAt)} von {data.knowledge.startedBy}. + Lauf ansehen +

+ {#if data.knowledge.stale} +

+ Seit der Analyse hat sich {data.knowledge.branch} geändert (jetzt {data.knowledge.headSha?.slice(0, 7)}). Aktualisiere die + Wissensbasis, damit Reviews vom aktuellen Stand ausgehen. Ein Update prüft nur, was sich geändert hat. +

+ {/if} + {#if !data.knowledge.complete} +

Die letzte Analyse wurde nicht vollständig abgeschlossen. Die Dokumente können Lücken haben.

+ {/if} +
+ +

Jeder Review dieses Repositorys bekommt diese Wissensbasis mit.

+ {:else if !runningAnalysis} +
+

Noch keine Wissensbasis

+

+ Bei einer Analyse liest Claudio den Code und geht die laufende App Bildschirm für Bildschirm durch. Heraus kommen fünf Dokumente: + Überblick, Module, Routen, App-Karte mit dem erwarteten Verhalten jedes Bildschirms und Notizen. Reviews nutzen sie, um betroffene + Bereiche schneller zu finden und Abweichungen vom bisherigen Verhalten zu erkennen. +

+
+ {/if} + + {#if !runningAnalysis && canWrite} + {#if !ready} +

+ Für eine Analyse braucht Claudio Rollen und die Datenschutz-Bestätigung. Einstellungen öffnen +

+ {:else} +
+ {data.knowledge ? 'Wissensbasis aktualisieren' : 'Analyse starten'} +
(analyzing = true)}> + {#if formError('analyze')}{/if} + +
+ Rollen +
+ {#each data.roles as role (role.name)} + + {/each} +
+ Claudio schaut sich die App mit jeder gewählten Rolle an. +
+
+ +
+ +
+ +
+
+
+ {/if} + {/if} + + {#if data.analyses.length} +
+

Bisherige Analysen

+
+ + + + + + {#each data.analyses as an (an.id)} + + + + + + + + {/each} + +
GestartetStatusCommitDokumenteKosten
{formatDate(an.createdAt)} von {an.startedBy}{#if an.commitSha}{an.commitSha.slice(0, 7)}{:else}–{/if}{an.docCount || '–'}{formatUsd(an.costUsd)}
+
+
+ {/if} +{:else if data.tab === 'reviews'} + {#if data.reviews.length} +
+ {:else} +

Für dieses Repository gibt es noch keine Reviews.

+ {/if} +{:else if !isAdmin} +

Die Einstellungen kann nur ändern, wer auf GitHub Admin-Rechte an diesem Repository hat.

+{:else} + {#if data.isNew} +

+ Repository hinzugefügt. Hinterlege als Nächstes den Anthropic-API-Key{data.keySources.length ? ' (oder übernimm ihn aus einem deiner Repositories)' : ''} und lege fest, als wer Claudio sich in der App anmeldet. +

+ {/if} + +
+
+

Anthropic-API-Key

+

Alle Modellaufrufe dieses Repositorys laufen über diesen Key und werden darüber abgerechnet. Ohne Key startet Claudio hier keine Reviews, Analysen oder Aufzeichnungen.

+
+
+ {#if formError('key')}{/if} + {#if data.anthropicKey} +

+ + Hinterlegt, endet auf …{data.anthropicKey.hint}{#if data.anthropicKey.by}, gesetzt von {data.anthropicKey.by}{/if}{#if data.anthropicKey.at}, {formatDate(data.anthropicKey.at)}{/if}. + {#if saved === 'key'}Gespeichert{/if} +

+ {:else if data.keyOptional} +

Kein Key hinterlegt. Das Demo-Repository nutzt auf dem Entwicklungsserver die Claude-Anmeldung dieses Rechners.

+ {:else} +

Noch kein Key hinterlegt.

+ {/if} + + {#if data.keySources.length} +
({ update }) => update({ reset: false })} class="row-form"> + + +
+ {/if} + +
+ + {#if form && 'unverified' in form && form.unverified} + + {/if} + +
+ + {#if data.anthropicKey} +
{ if (!confirm('Key entfernen? Danach startet Claudio für dieses Repository keine Modellaufrufe mehr.')) e.preventDefault(); }}> + +
+ {/if} +
+
+ +
+
+

Allgemein

+

Hinweise für den Agenten und wo die Instanzen der Pull Requests laufen.

+
+
({ update }) => update({ reset: false })} class="sheet fields"> + {#if formError('settings')}{/if} + + + + +
+ + {#if saved === 'settings'}Gespeichert{/if} +
+
+
+ +
+
+

Automatisierung

+

Reviews direkt aus GitHub starten und das Ergebnis am Pull Request anzeigen.

+
+
+
({ update }) => update({ reset: false })} class="sheet fields"> + {#if formError('automation')}{/if} + +
+ Review starten bei + + + +
+ + +
+ Automatische Reviews +
+ {#each data.roles as role (role.name)} + + {/each} +
+
+ + +
+ + +
+
+ + {#if saved === 'automation'}Gespeichert{/if} +
+
+ +
+

Webhook

+ {#if formError('webhook')}{/if} + {#if data.webhook} +

+ + Eingerichtet{data.webhook.by ? ` von ${data.webhook.by}` : ''}. {data.webhook.status} +

+ {:else} +

Noch nicht eingerichtet. Ohne Webhook startest du Reviews nur hier in Claudio.

+ {/if} + {#if data.localBaseUrl} +

+ GitHub kann localhost nicht erreichen. Setze PUBLIC_BASE_URL auf eine öffentliche Adresse (Server oder Tunnel wie cloudflared), bevor du den Webhook einrichtest. +

+ {/if} + +
+ So funktioniert das +
+

Was „Webhook einrichten“ tut: Claudio legt im Repository auf GitHub unter Settings → Webhooks einen Eintrag an. Darüber meldet GitHub neue und aktualisierte Pull Requests, Kommentare, Labels und Pushes an {data.webhookUrl}. Jede Meldung ist mit einem Geheimnis signiert, das nur dieses Repository und Claudio kennen.

+

Voraussetzungen:

+
    +
  • Du hast Admin-Rechte am Repository auf GitHub.
  • +
  • Claudio ist unter einer öffentlichen Adresse erreichbar (PUBLIC_BASE_URL).
  • +
  • Oben ist eine URL-Vorlage eingetragen, damit Claudio weiß, wo die Instanz eines PRs läuft.
  • +
+

Danach startet ein Review, sobald jemand im PR /claudio review kommentiert, das Label claudio-review setzt oder (falls eingeschaltet) neue Commits pusht. Claudio reagiert auf den Kommentar mit 👀 und schreibt die Kurzfassung als Kommentar zurück.

+

In wessen Namen: Automatische Läufe nutzen den GitHub-Zugang der Person, die den Webhook einrichtet. Kommentare und Status erscheinen unter ihrem Namen. Richtet ihn jemand anderes neu ein, wechselt das.

+

GitHub-Status als Pflicht-Check: Mit eingeschaltetem Status zeigt jeder PR einen Check „Claudio“: gelb während des Reviews, dann grün (Abnahme empfohlen) oder rot (Änderungen nötig). Damit rote Reviews das Mergen verhindern:

+
    +
  1. Einmal einen Review laufen lassen, damit GitHub den Check „Claudio“ kennt.
  2. +
  3. Auf GitHub: Settings → Branches → Branch protection rule für {data.repo.defaultBranch ?? 'den Hauptbranch'} anlegen oder bearbeiten.
  4. +
  5. „Require status checks to pass before merging“ anhaken und „Claudio“ auswählen.
  6. +
+

Entfernen löscht den Webhook auf GitHub. Bereits gestartete Reviews laufen zu Ende.

+
+
+ +
+
+ +
+ {#if data.webhook} +
+ +
+ {/if} +
+
+
+
+ +
+
+

Rollen

+

+ Benutzerkonten, mit denen Claudio sich anmeldet. Die Zugangsdaten werden verschlüsselt gespeichert und nie an das Modell + gegeben. +

+
+
+ {#if formError('role')}{/if} + {#each data.roles as role (role.name)} +
+ + {role.name} + {role.description || 'ohne Beschreibung'}, {role.username} + {#if saved === `role:${role.name}`}Gespeichert{/if} + + {@render roleForm(role)} +
+ + +
+
+ {/each} +
+ Neue Rolle + {@render roleForm(null)} +
+
+
+ +
+
+

Gelernte Entscheidungen

+

Entstehen, wenn jemand ein Finding mit Begründung verwirft. Jeder spätere Review bekommt sie als „gewollt, nicht melden“ mit.

+
+
+ {#if data.lessons.length} +
    + {#each data.lessons as lesson (lesson.reviewId + lesson.findingId)} +
  • +
    +

    {lesson.text}

    + Review #{lesson.reviewId}, {lesson.by}, {formatDate(lesson.at)} +
    +
    + + + +
    +
  • + {/each} +
+ {:else} +

Noch keine.

+ {/if} +
+
+ +
+
+

Entfernen

+

Nimmt das Repository aus Claudio heraus. Auf GitHub ändert sich nichts.

+
+
+ {#if formError('remove')}{/if} +
+
+
+{/if} + +{#snippet roleForm(role: (typeof data.roles)[number] | null)} +
({ update }) => update({ reset: !role })} class="fields"> +
+ + + + + + +
+ +
+
+{/snippet} + + diff --git a/apps/web/src/routes/reviews/[id]/+page.server.ts b/apps/web/src/routes/reviews/[id]/+page.server.ts new file mode 100644 index 0000000..cc60352 --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/+page.server.ts @@ -0,0 +1,151 @@ +import { error, fail } from '@sveltejs/kit'; +import { StrictnessSchema, blockingFindings, renderPrComment, sortBySeverity, verdict, withTests, type ReviewTestResult } from '@claudio/core'; +import { + addLesson, + eventsSince, + getReview, + listFindings, + listTestRuns, + openHumanRequest, + rateFinding, + removeLesson, + requestAbort, + setReviewCommentUrl, + FINDING_STATUSES +} from '@claudio/db'; +import type { AccessLevel } from '@claudio/db'; +import { createGithubClient, postPullRequestComment } from '@claudio/github'; +import { userToken } from '#lib/server/auth.js'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { publicBaseUrl } from '#lib/server/env.js'; +import { fileAvailability, runFileUrl, withAvailability } from '#lib/server/run-files.js'; +import { renderMarkdown } from '#lib/markdown.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadReview(idParam: string, user: App.Locals['user'], min: AccessLevel = 'read') { + const id = Number(idParam); + const review = Number.isInteger(id) ? await getReview(db(), id) : undefined; + if (!review) error(404, 'Diesen Review gibt es nicht.'); + // Visible only with access to its repository on GitHub. + await requireRepo(user, review.repoId, min); + return review; +} + +const dismissedOf = (rows: Awaited>) => + rows.filter((r) => r.finding.status === 'dismissed').map((r) => r.finding.findingId); + +export const load: PageServerLoad = async ({ params, url, locals }) => { + const review = await loadReview(params.id, locals.user, 'read'); + const [rows, events, testRuns] = await Promise.all([ + listFindings(db(), review.id), + eventsSince(db(), { reviewId: review.id }, 0, 5000), + listTestRuns(db(), { reviewId: review.id }, 100) + ]); + const tests: ReviewTestResult[] = testRuns.map((t) => ({ name: t.scriptName, outcome: t.outcome })); + const report = review.report; + + const ratings = new Map(rows.map((r) => [r.finding.findingId, { status: r.finding.status, comment: r.finding.comment ?? '', by: r.ratedByLogin, at: r.finding.ratedAt }])); + const dismissed = dismissedOf(rows); + const requested = StrictnessSchema.safeParse(url.searchParams.get('strictness')); + const strictness = requested.success ? requested.data : (report?.request.strictness ?? review.request.strictness); + const blocking = report ? new Set(blockingFindings(report, strictness, dismissed).map((f) => f.id)) : new Set(); + + const available = await fileAvailability(review.runId); + const evidence = report ? await withAvailability(review.runId, report.evidence) : []; + // The trace stays only in the (ephemeral) run directory: offered while it is there. + const traceUrl = review.runId && available('trace.zip') ? runFileUrl(review.runId, 'trace.zip') : null; + + return { + review: { + id: review.id, + repo: review.repoFullName, + prNumber: review.prNumber, + prTitle: review.prTitle, + status: review.status, + instanceUrl: review.instanceUrl, + request: review.request, + startedBy: review.startedByLogin, + createdAt: review.createdAt, + startedAt: review.startedAt, + finishedAt: review.finishedAt, + error: review.error, + abortRequested: review.abortRequested, + fixture: review.fixture, + commentUrl: review.commentUrl, + trigger: review.trigger + }, + commentPreview: report + ? renderMarkdown(renderPrComment(report, `${publicBaseUrl(url)}/reviews/${review.id}`, { strictness, dismissedIds: dismissed, tests })) + : null, + strictness, + report: report + ? { + pr: report.pr, + issues: report.issues, + summaryHtml: report.summary ? renderMarkdown(report.summary) : '', + verdict: withTests(verdict(report, strictness, dismissed), tests), + acceptanceCriteria: report.acceptanceCriteria, + findings: sortBySeverity(report.findings).map((f) => ({ + ...f, + blocking: blocking.has(f.id), + rating: ratings.get(f.id) ?? { status: 'proposed' as const, comment: '', by: null, at: null } + })), + evidence: Object.fromEntries(evidence.map((e) => [e.id, e])), + cost: report.cost, + errors: report.errors, + files: { trace: traceUrl } + } + : null, + events: events.map((e) => ({ seq: e.seq, line: e.line })), + human: await openHumanRequest(db(), 'review', review.id).then((h) => (h ? { id: h.id, reason: h.reason } : null)), + tests: testRuns + .map((t) => ({ id: t.id, scriptId: t.scriptId, name: t.scriptName, status: t.status, outcome: t.outcome, healed: t.healed, mode: t.mode, summary: t.report?.summary ?? '' })) + .reverse() + }; +}; + +export const actions: Actions = { + rate: async ({ params, request, locals }) => { + const review = await loadReview(params.id, locals.user, 'write'); + const f = await request.formData(); + const findingId = String(f.get('findingId') ?? ''); + const status = String(f.get('status') ?? ''); + if (!(FINDING_STATUSES as readonly string[]).includes(status)) return fail(400, { error: 'Unbekannte Bewertung.' }); + const finding = review.report?.findings.find((x) => x.id === findingId); + if (!finding) return fail(400, { error: 'Unbekanntes Finding.' }); + const comment = String(f.get('comment') ?? '').trim().slice(0, 2000) || null; + await rateFinding(db(), review.id, findingId, { status: status as (typeof FINDING_STATUSES)[number], comment, userId: locals.user!.id }); + // A dismissal with a reason teaches later reviews of this repo; undoing it forgets the lesson. + if (status === 'dismissed' && comment) { + await addLesson(db(), review.repoId, { text: `${finding.title}: ${comment}`, reviewId: review.id, findingId, by: locals.user!.login, at: new Date().toISOString() }); + } else { + await removeLesson(db(), review.repoId, { reviewId: review.id, findingId }); + } + return { rated: findingId }; + }, + comment: async ({ params, request, locals, url }) => { + const review = await loadReview(params.id, locals.user, 'write'); + if (!review.report) return fail(400, { error: 'Dieser Review hat keinen Bericht.', form: 'comment' }); + if (review.fixture) return fail(400, { error: 'Der Demo-PR existiert nicht auf GitHub, deshalb gibt es dort nichts zu kommentieren.', form: 'comment' }); + const token = await userToken(locals.user!); + if (!token) return fail(400, { error: 'Zum Kommentieren brauchst du eine Anmeldung mit GitHub.', form: 'comment' }); + const requested = StrictnessSchema.safeParse((await request.formData()).get('strictness')); + const strictness = requested.success ? requested.data : review.report.request.strictness; + const dismissed = dismissedOf(await listFindings(db(), review.id)); + const tests = (await listTestRuns(db(), { reviewId: review.id }, 100)).map((t) => ({ name: t.scriptName, outcome: t.outcome })); + const body = renderPrComment(review.report, `${publicBaseUrl(url)}/reviews/${review.id}`, { strictness, dismissedIds: dismissed, tests }); + try { + const commentUrl = await postPullRequestComment(createGithubClient(token), review.repoFullName, review.prNumber, body); + await setReviewCommentUrl(db(), review.id, commentUrl); + return { commented: commentUrl }; + } catch (e) { + return fail(400, { error: `GitHub hat den Kommentar abgelehnt: ${(e as Error).message}`, form: 'comment' }); + } + }, + abort: async ({ params, locals }) => { + const review = await loadReview(params.id, locals.user, 'write'); + await requestAbort(db(), review.id); + return { aborted: true }; + } +}; diff --git a/apps/web/src/routes/reviews/[id]/+page.svelte b/apps/web/src/routes/reviews/[id]/+page.svelte new file mode 100644 index 0000000..44f1082 --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/+page.svelte @@ -0,0 +1,777 @@ + + +#{review.prNumber} {review.prTitle ?? ''} · Claudio + +

Alle Reviews

+
+

#{review.prNumber} {review.prTitle ?? ''}

+ {#if active} +
+ +
+ {/if} +
+ +{#if report} + +
+
+

{VERDICT_LABEL[report.verdict]}

+

+ {acPassed} von {report.acceptanceCriteria.length} Akzeptanzkriterien erfüllt, + {counts.blocking === 0 ? 'kein blockierendes Finding' : counts.blocking === 1 ? 'ein blockierendes Finding' : `${counts.blocking} blockierende Findings`}{#if data.tests.length}, + {testsActive ? `Regressionstests laufen (${testsPassed} von ${data.tests.length} bestanden)` : `${testsPassed} von ${data.tests.length} Regressionstests bestanden`}{/if}{#if review.status !== 'completed'}, Run: {STATUS_LABEL[review.status]}{/if}. +

+
+
+ + +
+
+{:else} +
+ +

+ {#if review.status === 'queued'} + Wartet auf den Worker. Läuft kein Worker, starte ihn mit pnpm worker. + {:else if review.status === 'running'} + Claudio prüft gerade {review.instanceUrl}. + {:else} + Dieser Review hat keinen Bericht erzeugt. + {/if} +

+
+{/if} + +{#if review.error && !report} +

{review.error}

+{/if} + +
+
+ {#if active && data.human} + + {:else if active} + + {/if} + + {#if isActive(data.review.status) || data.events.length > 0} + (liveStatus = s)} + online={(line) => /^Mensch (gebraucht|fertig):/.test(line) && invalidateAll()} + /> + {/if} + + {#if report} + {#if report.summaryHtml} +
+

Zusammenfassung

+
{@html report.summaryHtml}
+
+ {/if} + + {#if data.tests.length} +
+

Regressionstests

+
    + {#each data.tests as t (t.id)} +
  • + +
    + {t.name} + {#if t.summary && t.outcome !== 'passed'}

    {t.summary}

    {/if} +
    +
  • + {/each} +
+
+ {/if} + +
+

Akzeptanzkriterien

+ {#if report.acceptanceCriteria.length} +
    + {#each report.acceptanceCriteria as ac, i (i)} +
  • + {AC_LABEL[ac.status]} +
    +

    {ac.criterion}

    + {#if ac.notes}

    {ac.notes}

    {/if} + {#if ac.evidenceIds.length} + + {/if} +
    +
  • + {/each} +
+ {:else} +

Claudio hat keine Akzeptanzkriterien bewertet.

+ {/if} +
+ +
+
+

Findings

+ {#if counts.dismissed > 0} + + {/if} +
+ {#if report.findings.length === 0} +

Keine Findings.

+ {/if} + {#each report.findings as f (f.id)} + {#if showDismissed || f.rating.status !== 'dismissed'} +
+
+ + {CATEGORY_LABEL[f.category] ?? f.category} + {#if f.blocking}blockiert die Abnahme{/if} + {f.id} +
+

{f.title}

+

{f.description}

+ {#if f.steps.length} +
    + {#each f.steps as step, i (i)}
  1. {step}
  2. {/each} +
+ {/if} + {#if f.expected || f.actual} +
+ {#if f.expected}
Erwartet
{f.expected}
{/if} + {#if f.actual}
Beobachtet
{f.actual}
{/if} +
+ {/if} + {#if f.url}

{f.url}

{/if} + {#if f.evidenceIds.length} +
+ {#each f.evidenceIds as eid (eid)} + {@const ev = report.evidence[eid]} + {#if ev?.url && ev.kind === 'screenshot'} + + {:else if ev?.expired} +
+ Screenshot gelöscht + {eid} {ev.label} +
+ {/if} + {/each} +
+ {/if} + +
({ update }) => update({ reset: false })}> + + +
+ + + {#if f.rating.status !== 'proposed'} + + {/if} +
+ {#if f.rating.by} +

+ {f.rating.status === 'confirmed' ? 'Bestätigt' : f.rating.status === 'dismissed' ? 'Verworfen' : 'Zurückgesetzt'} von {f.rating.by}{f.rating.at ? `, ${formatDate(f.rating.at)}` : ''} +

+ {/if} +
+
+ {/if} + {/each} +
+ + {#if !review.fixture && review.status !== 'queued' && review.status !== 'running'} +
+

Kommentar im Pull Request

+ {#if commentUrl} +

Kurzfassung gepostet. Kommentar auf GitHub öffnen

+ {:else} +

+ Bewerte die Findings zuerst. Der Kommentar übernimmt die Strenge von oben und lässt verworfene Findings weg. +

+ {/if} + {#if commentError}{/if} +
+ Vorschau +
{@html data.commentPreview}
+
+
{ posting = true; return async ({ update }) => { await update({ reset: false }); posting = false; }; }}> + + +
+
+ {/if} + + {#if report.errors.length} +
+

Probleme während des Runs

+
    + {#each report.errors as err, i (i)}
  • {err}
  • {/each} +
+
+ {/if} + {/if} +
+ + +
+ + (lightbox = null)} onclick={(e) => e.target === dialog && dialog?.close()}> + {#if lightbox} +
+ {lightbox.label} +
+ {lightbox.label} + In neuem Tab öffnen + +
+
+ {/if} +
+ + diff --git a/apps/web/src/routes/reviews/[id]/events/+server.ts b/apps/web/src/routes/reviews/[id]/events/+server.ts new file mode 100644 index 0000000..e0463b4 --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/events/+server.ts @@ -0,0 +1,14 @@ +import { error } from '@sveltejs/kit'; +import { getReview } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { runEventStream } from '#lib/server/sse.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, request, locals }) => { + const id = Number(params.id); + const item = Number.isInteger(id) ? await getReview(db(), id) : undefined; + if (!item) error(404, 'Diesen Review gibt es nicht.'); + await requireRepo(locals.user, item.repoId, 'read'); + return runEventStream(request, url, { reviewId: id }, async () => (await getReview(db(), id))?.status ?? null); +}; diff --git a/apps/web/src/routes/reviews/[id]/live/+server.ts b/apps/web/src/routes/reviews/[id]/live/+server.ts new file mode 100644 index 0000000..6825a16 --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/live/+server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { getReview } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { liveFrame } from '#lib/server/live.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, locals }) => { + const id = Number(params.id); + const item = Number.isInteger(id) ? await getReview(db(), id) : undefined; + if (!item) error(404, 'Diesen Review gibt es nicht.'); + await requireRepo(locals.user, item.repoId, 'read'); + return liveFrame(async () => { + const r = await getReview(db(), id); + return r ? { status: r.status, runId: r.runId } : null; + }); +}; diff --git a/apps/web/src/routes/reviews/[id]/report.md/+server.ts b/apps/web/src/routes/reviews/[id]/report.md/+server.ts new file mode 100644 index 0000000..e8ef354 --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/report.md/+server.ts @@ -0,0 +1,22 @@ +import { error } from '@sveltejs/kit'; +import { StrictnessSchema, VerbositySchema, renderMarkdown } from '@claudio/core'; +import { getReview, listFindings } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import type { RequestHandler } from './$types'; + +/** The report as Markdown, with the strictness and verbosity chosen in the UI and without dismissed findings. */ +export const GET: RequestHandler = async ({ params, url, locals }) => { + const id = Number(params.id); + const review = Number.isInteger(id) ? await getReview(db(), id) : undefined; + if (!review?.report) error(404, 'Für diesen Review gibt es keinen Bericht.'); + await requireRepo(locals.user, review.repoId, 'read'); + const strictness = StrictnessSchema.catch(review.report.request.strictness).parse(url.searchParams.get('strictness')); + const verbosity = VerbositySchema.catch(review.report.request.verbosity).parse(url.searchParams.get('verbosity')); + const dismissedIds = (await listFindings(db(), review.id)).filter((r) => r.finding.status === 'dismissed').map((r) => r.finding.findingId); + const markdown = renderMarkdown(review.report, verbosity, { strictness, dismissedIds }); + const name = `claudio-${review.repoFullName.replace('/', '-')}-pr${review.prNumber}-${verbosity}.md`; + return new Response(markdown, { + headers: { 'content-type': 'text/markdown; charset=utf-8', 'content-disposition': `attachment; filename="${name}"` } + }); +}; diff --git a/apps/web/src/routes/reviews/new/+page.server.ts b/apps/web/src/routes/reviews/new/+page.server.ts new file mode 100644 index 0000000..974a5d7 --- /dev/null +++ b/apps/web/src/routes/reviews/new/+page.server.ts @@ -0,0 +1,103 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { ReviewRequestSchema } from '@claudio/core'; +import { createReview, createTestRuns, listRoles, listTestScripts } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { keyOptional } from '#lib/server/anthropic.js'; +import { userToken } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { DEMO_FIXTURE, DEMO_REPO, demoPullRequest } from '#lib/server/demo.js'; +import { devFixtures } from '#lib/server/env.js'; +import { getPull, githubErrorMessage } from '#lib/server/github.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function context(url: URL, user: App.Locals['user']) { + const fullName = url.searchParams.get('repo') ?? ''; + const prNumber = Number(url.searchParams.get('pr')); + const { repo } = await requireRepo(user, fullName, 'write'); + if (!Number.isInteger(prNumber) || prNumber <= 0) error(400, 'Pull-Request-Nummer fehlt.'); + const roles = await listRoles(db(), repo.id); + if (!repo.dataConfirmed || roles.length === 0) redirect(303, `/repos/${repo.fullName}?tab=settings`); + if (!repo.anthropicKeyEnc && !keyOptional(repo)) redirect(303, `/repos/${repo.fullName}?tab=settings#anthropic`); + + const fixture = repo.fullName === DEMO_REPO && devFixtures() ? DEMO_FIXTURE : null; + let pr: { title: string; headRef: string; url: string } | null = null; + let prError: string | null = null; + if (fixture) { + const p = demoPullRequest(); + if (p.number === prNumber) pr = { title: p.title, headRef: p.headRef, url: p.url }; + } else { + const token = await userToken(user!); + if (!token) prError = 'Ohne GitHub-Anmeldung kann Claudio den Pull Request nicht laden.'; + else + try { + pr = await getPull(token, repo.fullName, prNumber); + } catch (e) { + prError = githubErrorMessage(e); + } + } + return { repo, roles, prNumber, pr, prError, fixture }; +} + +export const load: PageServerLoad = async ({ url, locals }) => { + const { repo, roles, prNumber, pr, prError } = await context(url, locals.user); + const tests = (await listTestScripts(db(), repo.id)).filter((t) => t.runInReviews); + return { + reviewTests: tests.map((t) => t.name), + repo: repo.fullName, + prNumber, + pr, + prError, + roles: roles.map((r) => ({ name: r.name, description: r.description })), + defaultInstanceUrl: repo.defaultInstanceUrl ?? '' + }; +}; + +export const actions: Actions = { + default: async ({ url, request, locals }) => { + const { repo, prNumber, pr, fixture } = await context(url, locals.user); + if (!pr) return fail(400, { error: 'Der Pull Request konnte nicht geladen werden.' }); + const f = await request.formData(); + const parsed = ReviewRequestSchema.safeParse({ + repo: repo.fullName, + pr: prNumber, + instanceUrl: String(f.get('instanceUrl') ?? '').trim(), + roles: f.getAll('roles').map(String), + driver: 'playwright', + budget: f.get('budget'), + verbosity: f.get('verbosity'), + strictness: f.get('strictness'), + language: f.get('language'), + postComment: f.get('postComment') === 'on' && !fixture, + cloneRepo: !fixture, + extraInstructions: String(f.get('extraInstructions') ?? '').trim() || undefined + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const message = issue?.path[0] === 'instanceUrl' ? 'Gib die vollständige Adresse der Instanz an, z. B. https://pr-17.preview.example.de.' : (issue?.message ?? 'Eingaben prüfen.'); + return fail(400, { error: message }); + } + const id = await createReview(db(), { + repoId: repo.id, + prNumber, + prTitle: pr.title, + request: parsed.data, + fixture, + startedBy: locals.user!.id + }); + // Regression tests of the repo run against the same instance, after the review (worker order). + if (f.get('runTests') === 'on') { + const tests = (await listTestScripts(db(), repo.id)).filter((t) => t.runInReviews); + await createTestRuns( + db(), + tests.map((t) => ({ + scriptId: t.id, + repoId: repo.id, + reviewId: id, + request: { repo: repo.fullName, instanceUrl: parsed.data.instanceUrl, budget: 'quick' as const, language: parsed.data.language, headed: false, rerecord: false }, + startedBy: locals.user!.id + })) + ); + } + redirect(303, `/reviews/${id}`); + } +}; diff --git a/apps/web/src/routes/reviews/new/+page.svelte b/apps/web/src/routes/reviews/new/+page.svelte new file mode 100644 index 0000000..50dad9c --- /dev/null +++ b/apps/web/src/routes/reviews/new/+page.svelte @@ -0,0 +1,155 @@ + + +Review starten · Claudio + +

Review starten

+ +{#if data.pr} +

+ #{data.prNumber} {data.pr.title} + Branch {data.pr.headRef} +

+{:else} +

{data.prError ?? `Pull Request #${data.prNumber} wurde nicht gefunden.`}

+{/if} + +
(submitting = true)}> + {#if form?.error}{/if} + + + +
+ Anmelden als +
+ {#each data.roles as role, i (role.name)} + + {/each} +
+ Claudio startet mit diesen Rollen und darf bei Bedarf zu den anderen wechseln. +
+ +
+ + + + +
+ + + + {#if data.reviewTests.length} + + {/if} + + + +
+ + Strenge und Ausführlichkeit lassen sich nach dem Review noch ändern. +
+
+ + diff --git a/apps/web/src/routes/test-runs/[id]/+page.server.ts b/apps/web/src/routes/test-runs/[id]/+page.server.ts new file mode 100644 index 0000000..d4e2cf2 --- /dev/null +++ b/apps/web/src/routes/test-runs/[id]/+page.server.ts @@ -0,0 +1,87 @@ +import { error } from '@sveltejs/kit'; +import { describeStep, resolveStepTemplates, sortBySeverity } from '@claudio/core'; +import { eventsSince, getTestRun, openHumanRequest, requestTestRunAbort } from '@claudio/db'; +import type { AccessLevel } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { withAvailability } from '#lib/server/run-files.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadRun(idParam: string, user: App.Locals['user'], min: AccessLevel = 'read') { + const id = Number(idParam); + const run = Number.isInteger(id) ? await getTestRun(db(), id) : undefined; + if (!run) error(404, 'Diesen Testlauf gibt es nicht.'); + // Visible only with access to its repository on GitHub. + await requireRepo(user, run.repoId, min); + return run; +} + +/** Same tag the runner used for {{run}}, so the page shows the actual test data. */ +const runTagOf = (runId: string) => runId.split('-').slice(-2).join(''); + +export const load: PageServerLoad = async ({ params, locals }) => { + const run = await loadRun(params.id, locals.user, 'read'); + const events = await eventsSince(db(), { testRunId: run.id }, 0, 5000); + const r = run.report; + const tplCtx = r ? { runTag: runTagOf(r.runId), now: new Date(r.createdAt) } : null; + return { + run: { + id: run.id, + scriptId: run.scriptId, + scriptName: run.scriptName, + repo: run.repoFullName, + status: run.status, + outcome: run.outcome, + healed: run.healed, + instanceUrl: run.instanceUrl, + budget: run.request.budget, + rerecord: run.request.rerecord, + reviewId: run.reviewId, + stepsVersion: run.stepsVersion, + startedBy: run.startedByLogin, + createdAt: run.createdAt, + finishedAt: run.finishedAt, + abortRequested: run.abortRequested, + error: run.error + }, + report: r + ? { + mode: r.mode, + summary: r.summary, + healedAt: r.healedAt ?? null, + judge: r.judge ?? null, + finalUrl: r.finalUrl ?? '', + savedSteps: r.compiledSteps?.length ?? 0, + determinismProposal: r.determinismProposal ?? null, + steps: r.steps.map((s) => ({ + index: s.index, + text: describeStep(tplCtx ? resolveStepTemplates(s.step, tplCtx) : s.step), + ok: s.ok, + error: s.error ?? null, + durationMs: s.durationMs, + healed: s.healed, + assertion: s.step.action.startsWith('expect_') + })), + evidence: (await withAvailability(r.runId, r.evidence.filter((e) => e.path && e.kind === 'screenshot'))).map((e) => ({ + id: e.id, + label: e.label, + url: e.url, + expired: e.expired + })), + findings: sortBySeverity(r.findings), + cost: r.cost, + errors: r.errors + } + : null, + events: events.map((e) => ({ seq: e.seq, line: e.line })), + human: await openHumanRequest(db(), 'test', run.id).then((h) => (h ? { id: h.id, reason: h.reason } : null)) + }; +}; + +export const actions: Actions = { + abort: async ({ params, locals }) => { + const run = await loadRun(params.id, locals.user, 'write'); + await requestTestRunAbort(db(), run.id); + return { aborted: true }; + } +}; diff --git a/apps/web/src/routes/test-runs/[id]/+page.svelte b/apps/web/src/routes/test-runs/[id]/+page.svelte new file mode 100644 index 0000000..003e4eb --- /dev/null +++ b/apps/web/src/routes/test-runs/[id]/+page.svelte @@ -0,0 +1,387 @@ + + +{run.scriptName} · Testlauf · Claudio + +

Tests / {run.scriptName}

+
+

Testlauf: {run.scriptName}

+ {#if active} +
+ +
+ {/if} +
+ +{#if r && !active} +
+

{run.outcome ? OUTCOME_LABEL[run.outcome] : STATUS_LABEL[run.status]}

+

+ {MODE_LABEL[r.mode]}{r.mode === 'replay' && run.stepsVersion ? ` von Version ${run.stepsVersion}` : ''}, + {r.steps.filter((s) => s.ok).length} von {r.steps.length} Schritten ausgeführt, {formatDuration(r.cost.durationMs)}, + {r.cost.usd > 0 ? formatUsd(r.cost.usd) : 'ohne Modell'}. +

+ {#if r.summary}

{r.summary}

{/if} +
+{:else if !active} +

{run.error ?? 'Dieser Lauf hat kein Ergebnis erzeugt.'}

+{/if} + +
+
+ {#if active && data.human} + + {:else if active} + + {/if} + {#if isActive(data.run.status) || data.events.length > 0} + (liveStatus = s)} + online={(line) => /^Mensch (gebraucht|fertig):/.test(line) && invalidateAll()} + /> + {/if} + + {#if r} + {#if r.healedAt !== null} +

+ Schritt {r.healedAt + 1} ließ sich nicht mehr ausführen. Claudio hat den Ablauf ab dort repariert{r.savedSteps ? ' und als neue Version gespeichert' : ''}. Prüfe unten das Finding: Ein umbenanntes oder verschobenes Element kann gewollt sein oder eine Regression. +

+ {:else if r.mode === 'record' && r.savedSteps} +

+ {r.savedSteps} Schritte aufgezeichnet{r.determinismProposal ? `, eingestuft als ${DETERMINISM_LABEL[r.determinismProposal].toLowerCase()}` : ''}. Folgeläufe spielen sie ohne Modell ab. +

+ {/if} + + {#if r.judge} +
+

Bewertung durch den Prüf-Agenten

+
+

+ {r.judge.passed ? 'Erwartung erfüllt' : 'Erwartung nicht erfüllt'} + Konfidenz {Math.round(r.judge.confidence * 100)} % +

+ {#if r.judge.deviations.length} +
    + {#each r.judge.deviations as d, i (i)}
  • {d}
  • {/each} +
+ {/if} +

{r.judge.reasoning}

+
+
+ {/if} + +
+

Schritte

+
    + {#each r.steps as step (step.index)} +
  1. + {step.ok ? '✓' : '✗'} + + {step.text} + {#if step.healed}repariert{/if} + {#if step.error}{step.error}{/if} + + {step.durationMs} ms +
  2. + {/each} +
+ {#if r.mode === 'record'} +

Beim Aufzeichnen verworfene Versuche (✗) werden nicht gespeichert.

+ {/if} +
+ + {#if r.findings.length} +
+

Findings

+
    + {#each r.findings as f (f.id)} +
  • + +
    + {f.title} + {CATEGORY_LABEL[f.category] ?? f.category} +

    {f.description}

    +
    +
  • + {/each} +
+
+ {/if} + + {#if r.evidence.length} +
+

Screenshots

+
+ {#each r.evidence as ev (ev.id)} + {#if ev.url} + + {ev.label} + {ev.label} + + {:else} +
+ Screenshot gelöscht + {ev.label} +
+ {/if} + {/each} +
+
+ {/if} + + {#if r.errors.length} +
+

Probleme während des Laufs

+
    {#each r.errors as e, i (i)}
  • {e}
  • {/each}
+
+ {/if} + {/if} +
+ + +
+ + diff --git a/apps/web/src/routes/test-runs/[id]/events/+server.ts b/apps/web/src/routes/test-runs/[id]/events/+server.ts new file mode 100644 index 0000000..343cf4a --- /dev/null +++ b/apps/web/src/routes/test-runs/[id]/events/+server.ts @@ -0,0 +1,14 @@ +import { error } from '@sveltejs/kit'; +import { getTestRun } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { runEventStream } from '#lib/server/sse.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, request, locals }) => { + const id = Number(params.id); + const item = Number.isInteger(id) ? await getTestRun(db(), id) : undefined; + if (!item) error(404, 'Diesen Testlauf gibt es nicht.'); + await requireRepo(locals.user, item.repoId, 'read'); + return runEventStream(request, url, { testRunId: id }, async () => (await getTestRun(db(), id))?.status ?? null); +}; diff --git a/apps/web/src/routes/test-runs/[id]/live/+server.ts b/apps/web/src/routes/test-runs/[id]/live/+server.ts new file mode 100644 index 0000000..beb15d4 --- /dev/null +++ b/apps/web/src/routes/test-runs/[id]/live/+server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { getTestRun } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { liveFrame } from '#lib/server/live.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, locals }) => { + const id = Number(params.id); + const item = Number.isInteger(id) ? await getTestRun(db(), id) : undefined; + if (!item) error(404, 'Diesen Testlauf gibt es nicht.'); + await requireRepo(locals.user, item.repoId, 'read'); + return liveFrame(async () => { + const r = await getTestRun(db(), id); + return r ? { status: r.status, runId: r.runId } : null; + }); +}; diff --git a/apps/web/src/routes/tests/[id]/+page.server.ts b/apps/web/src/routes/tests/[id]/+page.server.ts new file mode 100644 index 0000000..2ab9d28 --- /dev/null +++ b/apps/web/src/routes/tests/[id]/+page.server.ts @@ -0,0 +1,118 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { DeterminismSchema, TestRunRequestSchema, describeStep, parseHostList } from '@claudio/core'; +import { + createTestRun, + deleteTestScript, + getRepoById, + getTestScript, + listRoles, + listTestRuns, + resetTestSteps, + updateTestScript +} from '@claudio/db'; +import type { AccessLevel } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { NO_KEY_MESSAGE, keyOptional } from '#lib/server/anthropic.js'; +import { db } from '#lib/server/db.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadScript(idParam: string, user: App.Locals['user'], min: AccessLevel = 'read') { + const id = Number(idParam); + const script = Number.isInteger(id) ? await getTestScript(db(), id) : undefined; + if (!script) error(404, 'Diesen Test gibt es nicht.'); + // Visible only with access to its repository on GitHub. + await requireRepo(user, script.repoId, min); + return script; +} + +const text = (v: FormDataEntryValue | null) => String(v ?? '').trim(); + +export const load: PageServerLoad = async ({ params, url, locals }) => { + const script = await loadScript(params.id, locals.user, 'read'); + const [repo, roles, runs] = await Promise.all([getRepoById(db(), script.repoId), listRoles(db(), script.repoId), listTestRuns(db(), { scriptId: script.id }, 50)]); + return { + script: { + id: script.id, + repo: script.repoFullName, + name: script.name, + description: script.description, + expectation: script.expectation, + roles: script.roles ?? [], + determinism: script.determinism, + runInReviews: script.runInReviews, + allowedHosts: script.allowedHosts ?? [], + allowAnyHost: script.allowAnyHost, + stepsVersion: script.stepsVersion, + stepsUpdatedAt: script.stepsUpdatedAt, + steps: (script.steps ?? []).map((s) => ({ text: describeStep(s), assertion: s.action.startsWith('expect_') })) + }, + roles: roles.map((r) => r.name), + defaultInstanceUrl: repo?.defaultInstanceUrl ?? '', + dataConfirmed: repo?.dataConfirmed ?? false, + runs: runs.map((r) => ({ + id: r.id, + status: r.status, + outcome: r.outcome, + mode: r.mode, + healed: r.healed, + stepsVersion: r.stepsVersion, + costUsd: r.costUsd, + durationMs: r.report?.cost.durationMs ?? null, + reviewId: r.reviewId, + startedBy: r.startedByLogin, + createdAt: r.createdAt + })), + isNew: url.searchParams.has('new') + }; +}; + +export const actions: Actions = { + update: async ({ params, request, locals }) => { + const script = await loadScript(params.id, locals.user, 'write'); + const f = await request.formData(); + const determinism = DeterminismSchema.safeParse(f.get('determinism')); + const input = { + name: text(f.get('name')), + description: text(f.get('description')), + expectation: text(f.get('expectation')), + roles: f.getAll('roles').map(String), + runInReviews: f.get('runInReviews') === 'on', + allowedHosts: parseHostList(text(f.get('allowedHosts'))), + allowAnyHost: f.get('allowAnyHost') === 'on', + determinism: determinism.success ? determinism.data : script.determinism + }; + if (!input.name || !input.description || !input.expectation) return fail(400, { error: 'Name, Ablauf und erwartetes Ergebnis sind Pflicht.', form: 'update' }); + await updateTestScript(db(), script.id, input); + return { saved: true }; + }, + run: async ({ params, request, locals }) => { + const script = await loadScript(params.id, locals.user, 'write'); + const repo = await getRepoById(db(), script.repoId); + if (!repo?.dataConfirmed) return fail(400, { error: 'Bestätige zuerst in den Repo-Einstellungen, dass die Instanz nur Testdaten enthält.', form: 'run' }); + const f = await request.formData(); + // Without a key only a model-free replay can run: recorded steps, deterministic, no re-recording. + const needsModel = !script.steps?.length || script.determinism !== 'deterministic' || f.get('rerecord') === 'on'; + if (repo && !repo.anthropicKeyEnc && !keyOptional(repo) && needsModel) { + return fail(400, { error: `${NO_KEY_MESSAGE} Ohne Key laufen nur deterministische Tests, die schon aufgezeichnet sind.`, form: 'run' }); + } + const parsed = TestRunRequestSchema.safeParse({ + repo: script.repoFullName, + instanceUrl: text(f.get('instanceUrl')), + budget: f.get('budget'), + rerecord: f.get('rerecord') === 'on' + }); + if (!parsed.success) return fail(400, { error: 'Gib die vollständige Adresse einer laufenden Instanz an.', form: 'run' }); + const id = await createTestRun(db(), { scriptId: script.id, repoId: script.repoId, request: parsed.data, startedBy: locals.user!.id }); + redirect(303, `/test-runs/${id}`); + }, + reset: async ({ params, locals }) => { + const script = await loadScript(params.id, locals.user, 'write'); + await resetTestSteps(db(), script.id); + return { saved: true }; + }, + delete: async ({ params, locals }) => { + const script = await loadScript(params.id, locals.user, 'write'); + await deleteTestScript(db(), script.id); + redirect(303, `/repos/${script.repoFullName}?tab=tests`); + } +}; diff --git a/apps/web/src/routes/tests/[id]/+page.svelte b/apps/web/src/routes/tests/[id]/+page.svelte new file mode 100644 index 0000000..1241e4d --- /dev/null +++ b/apps/web/src/routes/tests/[id]/+page.svelte @@ -0,0 +1,334 @@ + + +{s.name} · Tests · Claudio + +

Alle Tests

+

{s.name}

+

+ {#if s.determinism} + {DETERMINISM_LABEL[s.determinism]}. {DETERMINISM_HELP[s.determinism]} + {:else} + Noch nicht aufgezeichnet. Der erste Lauf zeichnet den Ablauf mit einem Agenten auf. + {/if} + {#if s.runInReviews}läuft bei PR-Reviews mit{/if} + {#if s.allowAnyHost}darf beliebige Websites öffnen{:else if s.allowedHosts.length}darf auch {s.allowedHosts.join(', ')} öffnen{/if} +

+ +{#if data.isNew} +

Test angelegt. Starte den ersten Lauf, damit Claudio den Ablauf aufzeichnet.

+{/if} + +
+
+
+

Ablauf

+

{s.description}

+

Erwartetes Ergebnis

+

{s.expectation}

+
+ +
+
+

Aufgezeichnete Schritte

+ {#if s.steps.length} + Version {s.stepsVersion}, {formatDate(s.stepsUpdatedAt)} + {/if} +
+ {#if s.steps.length} +
    + {#each s.steps as step, i (i)} +
  1. {step.text}
  2. + {/each} +
+
+ + Der nächste Lauf zeichnet dann neu auf. +
+ {:else} +

Noch keine. Sie entstehen beim ersten Lauf.

+ {/if} +
+ +
+
+

Läufe

+ {#if data.runs.length > 1} + + Ausgewählte vergleichen + + {/if} +
+ {#if data.runs.length} +
+ + + + + + + + + + + + + {#each data.runs as run (run.id)} + + + + + + + + + {/each} + +
VergleichenGestartetErgebnisArtDauerKosten
+ toggleCompare(run.id, e.currentTarget.checked)} + /> + + {formatDate(run.createdAt)} + {run.reviewId ? `im Review #${run.reviewId}` : `von ${run.startedBy}`} + {run.mode ? MODE_LABEL[run.mode] : '–'}{run.mode === 'replay' && run.stepsVersion ? ` v${run.stepsVersion}` : ''}{run.durationMs !== null ? formatDuration(run.durationMs) : '–'}{formatUsd(run.costUsd)}
+
+ {:else} +

Noch keine Läufe.

+ {/if} +
+
+ + +
+ +
+ Test bearbeiten +
({ update }) => update({ reset: false })}> + {#if formError('update')}{/if} + + + +
+ Rollen +
+ {#each data.roles as role (role)} + + {/each} +
+
+ +
+ Andere Websites + + +
+ +
+
+
{ if (!confirm('Test und alle Läufe löschen?')) e.preventDefault(); }}> + +
+
+ + diff --git a/apps/web/src/routes/tests/[id]/compare/+page.server.ts b/apps/web/src/routes/tests/[id]/compare/+page.server.ts new file mode 100644 index 0000000..733bb24 --- /dev/null +++ b/apps/web/src/routes/tests/[id]/compare/+page.server.ts @@ -0,0 +1,48 @@ +import { error } from '@sveltejs/kit'; +import { describeStep, resolveStepTemplates } from '@claudio/core'; +import { getTestRun, getTestScript } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import { fileAvailability, runFileUrl } from '#lib/server/run-files.js'; +import type { PageServerLoad } from './$types'; + +const runTagOf = (runId: string) => runId.split('-').slice(-2).join(''); + +export const load: PageServerLoad = async ({ params, url, locals }) => { + const script = await getTestScript(db(), Number(params.id)); + if (!script) error(404, 'Diesen Test gibt es nicht.'); + await requireRepo(locals.user, script.repoId, 'read'); + const ids = [Number(url.searchParams.get('a')), Number(url.searchParams.get('b'))]; + const runs = await Promise.all(ids.map((id) => (Number.isInteger(id) ? getTestRun(db(), id) : undefined))); + if (runs.some((r) => !r || r.scriptId !== script.id)) error(404, 'Wähle zwei Läufe dieses Tests aus.'); + + const availability = await Promise.all(runs.map((run) => fileAvailability(run!.report?.runId))); + const view = runs.map((run, i) => { + const r = run!.report; + const ctx = r ? { runTag: runTagOf(r.runId), now: new Date(r.createdAt) } : null; + const final = r?.evidence.find((e) => e.label === 'Endzustand' && e.path); + return { + id: run!.id, + createdAt: run!.createdAt, + status: run!.status, + outcome: run!.outcome, + healed: run!.healed, + mode: run!.mode, + stepsVersion: run!.stepsVersion, + durationMs: r?.cost.durationMs ?? null, + costUsd: run!.costUsd, + judge: r?.judge ?? null, + finalShot: r && final && availability[i]!(final.path!) ? runFileUrl(r.runId, final.path!) : null, + // Only executed steps line up between runs; discarded recording attempts would shift the rows. + steps: (r?.steps ?? []) + .filter((s) => s.ok || s.step.action.startsWith('expect_') || r?.mode === 'replay') + .map((s) => ({ text: describeStep(ctx ? resolveStepTemplates(s.step, ctx) : s.step), ok: s.ok, error: s.error ?? null, durationMs: s.durationMs })) + }; + }); + + const rows = Array.from({ length: Math.max(view[0]!.steps.length, view[1]!.steps.length) }, (_, i) => ({ + a: view[0]!.steps[i] ?? null, + b: view[1]!.steps[i] ?? null + })); + return { script: { id: script.id, name: script.name, repo: script.repoFullName }, a: view[0]!, b: view[1]!, rows }; +}; diff --git a/apps/web/src/routes/tests/[id]/compare/+page.svelte b/apps/web/src/routes/tests/[id]/compare/+page.svelte new file mode 100644 index 0000000..23fce2d --- /dev/null +++ b/apps/web/src/routes/tests/[id]/compare/+page.svelte @@ -0,0 +1,146 @@ + + +Vergleich · {data.script.name} · Claudio + +

Tests / {data.script.name}

+

Läufe vergleichen

+ +
+ + + + + {#each runs as run (run.id)} + + {/each} + + + + + + {#each runs as run (run.id)}{/each} + + + + {#each runs as run (run.id)}{/each} + + + + {#each runs as run (run.id)}{/each} + + + + {#each runs as run (run.id)} + + {/each} + + +
Lauf vom {formatDate(run.createdAt)}
Ergebnis
Art{run.mode ? MODE_LABEL[run.mode] : '–'}{run.mode === 'replay' && run.stepsVersion ? ` von Version ${run.stepsVersion}` : ''}
Dauer, Kosten{run.durationMs !== null ? formatDuration(run.durationMs) : '–'}, {formatUsd(run.costUsd)}
Prüf-Agent + {#if run.judge} + {run.judge.passed ? 'Erwartung erfüllt' : 'Nicht erfüllt'} ({Math.round(run.judge.confidence * 100)} %) + {#each run.judge.deviations as d, i (i)}{d}{/each} + {:else}nicht nötig{/if} +
+
+ +

Schritte

+
+ + + + {#each data.rows as row, i (i)} + + + {#each [row.a, row.b] as s, k (k)} + + {/each} + + {/each} + +
#Lauf ALauf B
{i + 1} + {#if s} + {s.ok ? '✓' : '✗'} + {s.text} + {s.durationMs} ms + {#if s.error}{s.error}{/if} + {:else}–{/if} +
+
+ +

Endzustand

+
+ {#each runs as run (run.id)} + {#if run.finalShot} + Endzustand des Laufs vom {formatDate(run.createdAt)} + {:else} +

Kein Screenshot vorhanden (Screenshots werden nach 14 Tagen gelöscht).

+ {/if} + {/each} +
+ + diff --git a/apps/web/src/routes/webhooks/github/+server.ts b/apps/web/src/routes/webhooks/github/+server.ts new file mode 100644 index 0000000..46675ea --- /dev/null +++ b/apps/web/src/routes/webhooks/github/+server.ts @@ -0,0 +1,37 @@ +import { json } from '@sveltejs/kit'; +import { getRepo, webhookSecret } from '@claudio/db'; +import { verifyWebhookSignature } from '@claudio/github'; +import { handleGithubEvent } from '#lib/server/automation.js'; +import { db } from '#lib/server/db.js'; +import { publicBaseUrl } from '#lib/server/env.js'; +import type { RequestHandler } from './$types'; + +/** + * GitHub webhook. Public, so the repo's secret (HMAC signature) is the only + * authentication: unsigned or wrongly signed requests are rejected before + * anything is parsed further. + */ +export const POST: RequestHandler = async ({ request, url }) => { + const raw = await request.text(); + let payload: Record; + try { + payload = JSON.parse(raw); + } catch { + return json({ error: 'invalid JSON' }, { status: 400 }); + } + const fullName = payload.repository?.full_name; + const repo = typeof fullName === 'string' ? await getRepo(db(), fullName) : undefined; + const secret = repo ? webhookSecret(repo) : null; + if (!repo || !secret || !verifyWebhookSignature(secret, raw, request.headers.get('x-hub-signature-256'))) { + return json({ error: 'unknown repository or invalid signature' }, { status: 401 }); + } + const event = request.headers.get('x-github-event') ?? ''; + try { + const result = await handleGithubEvent(repo, event, payload, publicBaseUrl(url)); + console.log(`[webhook] ${repo.fullName} ${event}${payload.action ? `.${payload.action}` : ''}: ${result.action} (${result.reason})`); + return json(result, { status: result.action === 'ignored' ? 200 : 202 }); + } catch (e) { + console.error(`[webhook] ${repo.fullName} ${event} failed`, e); + return json({ error: (e as Error).message }, { status: 500 }); + } +}; diff --git a/apps/web/static/robots.txt b/apps/web/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/apps/web/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/apps/web/test/files.test.ts b/apps/web/test/files.test.ts new file mode 100644 index 0000000..5a16709 --- /dev/null +++ b/apps/web/test/files.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { contentType, resolveRunFile } from '../src/lib/server/files.js'; + +const root = '/srv/claudio/runs'; + +describe('resolveRunFile', () => { + it('resolves files inside the run directory', () => { + expect(resolveRunFile(root, 'review-pr17-20261010-210308-clh8', 'evidence/E1-Fahrtenliste.png')).toBe( + '/srv/claudio/runs/review-pr17-20261010-210308-clh8/evidence/E1-Fahrtenliste.png' + ); + }); + it.each([ + ['review-1', '../review-2/report.json'], + ['review-1', 'evidence/../../review-2/x.png'], + ['review-1', '/etc/passwd'], + ['review-1', ''], + ['review-1', 'a\0b'], + ['..', 'runs/report.json'], + ['review/1', 'report.json'], + ['.', 'report.json'] + ])('rejects run %s file %s', (runId, file) => { + expect(resolveRunFile(root, runId, file)).toBeNull(); + }); +}); + +describe('contentType', () => { + it('maps known extensions and falls back to octet-stream', () => { + expect(contentType('x/E1.png')).toBe('image/png'); + expect(contentType('trace.zip')).toBe('application/zip'); + expect(contentType('x.html')).toBe('application/octet-stream'); + }); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..62f2df9 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "$app/tsconfig", + "compilerOptions": { + "strict": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..5cb99b5 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-node'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The monorepo's .env lives two levels up. + envDir: '../..', + plugins: [ + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => + filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + adapter: adapter() + }) + ] +}); diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 0000000..3c40aee --- /dev/null +++ b/apps/worker/package.json @@ -0,0 +1,19 @@ +{ + "name": "@claudio/worker", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Runs queued reviews from the database", + "scripts": { + "start": "tsx src/main.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "dev": "tsx src/main.ts" + }, + "dependencies": { + "@claudio/agent": "workspace:*", + "@claudio/core": "workspace:*", + "@claudio/db": "workspace:*", + "@claudio/github": "workspace:*", + "@claudio/browser": "workspace:*" + } +} diff --git a/apps/worker/src/artifacts.ts b/apps/worker/src/artifacts.ts new file mode 100644 index 0000000..832be00 --- /dev/null +++ b/apps/worker/src/artifacts.ts @@ -0,0 +1,31 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { deleteArtifactsBefore, saveArtifact, type Db } from "@claudio/db"; + +const IMAGE_TYPES: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" }; + +/** + * Copies a finished run's screenshots into the database: the run directory is + * ephemeral (emptyDir in Kubernetes), the reports must keep their evidence. + */ +export async function persistArtifacts(db: Db, run: { runId: string; runDir: string }, log: (msg: string) => void): Promise { + const dir = join(run.runDir, "evidence"); + let stored = 0; + for (const name of await readdir(dir).catch(() => [] as string[])) { + const type = IMAGE_TYPES[name.split(".").pop()?.toLowerCase() ?? ""]; + if (!type) continue; + const data = await readFile(join(dir, name)).catch(() => null); + if (!data || data.length === 0) continue; + await saveArtifact(db, { runId: run.runId, path: `evidence/${name}`, contentType: type, data }) + .then(() => stored++) + .catch((e) => log(`could not store ${name}: ${(e as Error).message}`)); + } + if (stored) log(`${run.runId}: ${stored} screenshot(s) stored`); +} + +/** Deletes stored screenshots older than the retention period. */ +export async function purgeOldArtifacts(db: Db, days: number, log: (msg: string) => void): Promise { + if (!(days > 0)) return; + const removed = await deleteArtifactsBefore(db, new Date(Date.now() - days * 86_400_000)).catch(() => 0); + if (removed) log(`deleted ${removed} screenshot(s) older than ${days} days`); +} diff --git a/apps/worker/src/finalize.ts b/apps/worker/src/finalize.ts new file mode 100644 index 0000000..d226d3f --- /dev/null +++ b/apps/worker/src/finalize.ts @@ -0,0 +1,76 @@ +import { blockingFindings, renderPrComment, verdict, withTests, type Verdict } from "@claudio/core"; +import { + dismissedFindingIds, + getRepoById, + getReview, + getUser, + setReviewCommentUrl, + testResultsByReview, + freshAccessToken, + type Db, +} from "@claudio/db"; +import { createGithubClient, postPullRequestComment, setCommitStatus, type CommitState } from "@claudio/github"; + +const ACTIVE = new Set(["queued", "running"]); + +const STATE: Record = { approve: "success", request_changes: "failure", inconclusive: "error" }; +const LABEL: Record = { approve: "Abnahme empfohlen", request_changes: "Änderungen nötig", inconclusive: "Review unvollständig" }; + +/** + * Reports a review back to GitHub once it and its regression tests are done: + * the commit status, and the PR comment if the review asked for one. Called + * after the review and after each of its test runs; does nothing until all + * are finished, and posts the comment only once. + */ +export async function finalizeReview(db: Db, reviewId: number, publicBaseUrl: string, log: (msg: string) => void): Promise { + const review = await getReview(db, reviewId); + if (!review || ACTIVE.has(review.status) || review.fixture) return; + const repo = await getRepoById(db, review.repoId); + if (!repo) return; + + const tests = (await testResultsByReview(db, [reviewId])).get(reviewId) ?? []; + const pendingTests = tests.filter((t) => ACTIVE.has(t.status)).length; + const user = await getUser(db, repo.automationUserId ?? review.startedBy); + const token = user ? await freshAccessToken(db, user) : null; + if (!token) return; + const github = createGithubClient(token); + const url = publicBaseUrl ? `${publicBaseUrl}/reviews/${reviewId}` : undefined; + const sha = review.headSha ?? review.report?.pr.headSha ?? null; + const wantsStatus = Boolean(repo.automation?.commitStatus && sha); + + if (pendingTests > 0) { + if (wantsStatus) { + await setCommitStatus(github, repo.fullName, sha!, "pending", `Review fertig, ${pendingTests} Regressionstest(s) laufen noch`, url).catch((e) => log(`commit status failed: ${(e as Error).message}`)); + } + return; + } + + const report = review.report; + const dismissed = (await dismissedFindingIds(db, [reviewId])).get(reviewId) ?? []; + const results = tests.map((t) => ({ name: t.name, outcome: t.outcome })); + const v: Verdict = report ? withTests(verdict(report, report.request.strictness, dismissed), results) : "inconclusive"; + + if (wantsStatus) { + const parts = report + ? [ + `${report.acceptanceCriteria.filter((a) => a.status === "passed").length}/${report.acceptanceCriteria.length} Kriterien`, + `${blockingFindings(report, report.request.strictness, dismissed).length} blockierend`, + ...(tests.length ? [`Tests ${tests.filter((t) => t.outcome === "passed").length}/${tests.length}`] : []), + ] + : [review.error ?? review.status]; + await setCommitStatus(github, repo.fullName, sha!, STATE[v], `${LABEL[v]}: ${parts.join(", ")}`, url) + .then(() => log(`review ${reviewId}: commit status ${STATE[v]}`)) + .catch((e) => log(`commit status failed: ${(e as Error).message}`)); + } + + if (report && review.request.postComment && !review.commentUrl) { + try { + const body = renderPrComment(report, url, { dismissedIds: dismissed, tests: results }); + const commentUrl = await postPullRequestComment(github, repo.fullName, review.prNumber, body); + await setReviewCommentUrl(db, reviewId, commentUrl); + log(`review ${reviewId}: PR comment ${commentUrl}`); + } catch (e) { + log(`PR comment failed: ${(e as Error).message}`); + } + } +} diff --git a/apps/worker/src/human.ts b/apps/worker/src/human.ts new file mode 100644 index 0000000..d54601d --- /dev/null +++ b/apps/worker/src/human.ts @@ -0,0 +1,47 @@ +import { applyHumanInput, type HumanBridgeFactory } from "@claudio/browser"; +import { closeHumanRequest, createHumanRequest, getHumanRequest, humanInputsSince, type Db, type RunKind } from "@claudio/db"; + +const TIMEOUT_MS = Number(process.env.CLAUDIO_HUMAN_TIMEOUT_MIN ?? 15) * 60_000; + +/** Prefixes the web UI watches for in the live log to switch the live view into takeover mode. */ +export const HUMAN_NEEDED = "Mensch gebraucht:"; +export const HUMAN_DONE = "Mensch fertig:"; + +/** + * Lets a person take over the run's browser: records the request, replays the + * clicks and keystrokes sent from the web UI, and returns when they continue, + * cancel, or do not respond in time. + */ +export function humanBridge(db: Db, run: { kind: RunKind; id: number; repoId: number }, emit: (line: string) => void, abort: AbortSignal): HumanBridgeFactory { + return (attachment) => ({ + async wait(reason) { + const requestId = await createHumanRequest(db, { runKind: run.kind, runRefId: run.id, repoId: run.repoId, reason }); + emit(`${HUMAN_NEEDED} ${reason}`); + const deadline = Date.now() + TIMEOUT_MS; + let lastInput = 0; + for (;;) { + if (abort.aborted) { + await closeHumanRequest(db, requestId, "cancelled", null); + emit(`${HUMAN_DONE} Run abgebrochen.`); + return "cancelled"; + } + for (const i of await humanInputsSince(db, requestId, lastInput)) { + lastInput = i.id; + const page = attachment.activePage(); + if (page) await applyHumanInput(page, i.input).catch(() => undefined); + } + const req = await getHumanRequest(db, requestId); + if (req && req.status !== "open") { + emit(`${HUMAN_DONE} ${req.status === "resolved" ? "Claudio macht weiter." : "Abgebrochen."}`); + return req.status === "resolved" ? "resolved" : "cancelled"; + } + if (Date.now() > deadline) { + await closeHumanRequest(db, requestId, "timeout", null); + emit(`${HUMAN_DONE} Keine Reaktion innerhalb von ${Math.round(TIMEOUT_MS / 60_000)} Minuten.`); + return "timeout"; + } + await new Promise((r) => setTimeout(r, 250)); + } + }, + }); +} diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts new file mode 100644 index 0000000..581b193 --- /dev/null +++ b/apps/worker/src/main.ts @@ -0,0 +1,316 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runAnalysis, runReview, runTest, type RunHandle } from "@claudio/agent"; +import { verdict } from "@claudio/core"; +import { + appendEvent, + claimNextAnalysis, + claimNextReview, + claimNextTestRun, + closeDb, + failStaleRuns, + finishAnalysis, + finishReview, + finishTestRun, + getDb, + getRepoById, + getTestScript, + getUser, + isAbortRequested, + isAnalysisAbortRequested, + isTestRunAbortRequested, + latestKnowledge, + listRoles, + repoAnthropicKey, + saveTestSteps, + setAnalysisRunId, + setReviewRunId, + setTestRunStarted, + toRepoConfig, + toTestScript, + freshAccessToken, + type Analysis, + type Db, + type EventTarget, + type Review, + type ReviewStatus, + type TestRun, +} from "@claudio/db"; +import { persistArtifacts, purgeOldArtifacts } from "./artifacts.js"; +import { finalizeReview } from "./finalize.js"; +import { humanBridge } from "./human.js"; +import { purgeOldRuns } from "./retention.js"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +if (existsSync(resolve(projectRoot, ".env"))) process.loadEnvFile(resolve(projectRoot, ".env")); + +const runsRoot = resolve(projectRoot, process.env.CLAUDIO_RUNS_DIR ?? "runs"); +const concurrency = Math.max(1, Number(process.env.CLAUDIO_WORKER_CONCURRENCY ?? 1)); +const pollMs = 2000; +/** Base URL of the web UI, for links in PR comments. */ +const publicBaseUrl = (process.env.PUBLIC_BASE_URL ?? "").replace(/\/$/, ""); + +/** Offline fixtures a run may use instead of GitHub (CLAUDIO_DEV_FIXTURES=1 only). */ +const FIXTURES: Record = { + demo: { + prContextFile: resolve(projectRoot, "apps/demo-target/fixtures/pr-context.json"), + repoDir: resolve(projectRoot, "apps/demo-target"), + }, +}; + +/** The offline demo may use the machine's own Claude login on the dev server; every real repo needs its own key. */ +const DEMO_REPO = "vanderlars/dispo-demo"; +const NO_KEY = "Für dieses Repository ist kein Anthropic-API-Key hinterlegt. Ein Admin kann ihn in den Repo-Einstellungen eintragen."; + +/** Soft-stop handles of the runs in progress, so a shutdown (deploy) can end them cleanly. */ +const runningStops = new Set(); +/** Run directories (traces, checkouts) and stored screenshots are deleted after this many days. */ +const retentionDays = Number(process.env.CLAUDIO_RUN_RETENTION_DAYS ?? 14); +const artifactRetentionDays = Number(process.env.CLAUDIO_ARTIFACT_RETENTION_DAYS ?? 14); + +const log = (msg: string) => console.log(`[worker] ${new Date().toISOString()} ${msg}`); + +type Job = + | { kind: "review"; row: Review } + | { kind: "analysis"; row: Analysis } + | { kind: "test"; row: TestRun & { fixture?: null } }; + +/** Live log, abort polling and the shared repo/user context of one run. */ +async function prepare(db: Db, job: Job) { + const target: EventTarget = + job.kind === "review" ? { reviewId: job.row.id } : job.kind === "analysis" ? { analysisId: job.row.id } : { testRunId: job.row.id }; + let seq = 0; + let pending: Promise = Promise.resolve(); + // onProgress is synchronous; chain the inserts so the live log keeps its order. + const emit = (line: string) => { + const n = ++seq; + pending = pending.then(() => appendEvent(db, target, n, line)).catch((e) => log(`event write failed: ${(e as Error).message}`)); + }; + const after = (fn: () => Promise) => { + pending = pending.then(fn).catch((e) => log(`write failed: ${(e as Error).message}`)); + }; + + // Cancelling first asks the agent to stop (soft), so the SDK still reports the cost; + // only if it does not finish within a minute is the process killed (hard). + const abort = new AbortController(); + const stop = new AbortController(); + runningStops.add(stop); + let hardAbort: ReturnType | undefined; + const abortRequested = job.kind === "review" ? isAbortRequested : job.kind === "analysis" ? isAnalysisAbortRequested : isTestRunAbortRequested; + const abortPoll = setInterval(() => { + abortRequested(db, job.row.id) + .then((yes) => { + if (yes && !stop.signal.aborted) { + emit("Abbruch angefordert, Claudio beendet den Lauf …"); + stop.abort(); + hardAbort = setTimeout(() => abort.abort(), 60_000); + } + }) + .catch(() => undefined); + }, pollMs); + + const repo = await getRepoById(db, job.row.repoId); + if (!repo) throw new Error(`Repo ${job.row.repoId} existiert nicht mehr.`); + const user = await getUser(db, job.row.startedBy); + const githubToken = user ? ((await freshAccessToken(db, user)) ?? undefined) : undefined; + const { config, roleEnv } = toRepoConfig(repo, await listRoles(db, repo.id)); + const knowledge = await latestKnowledge(db, repo.id); + const anthropicApiKey = repoAnthropicKey(repo) ?? undefined; + const keyOptional = process.env.CLAUDIO_DEV_FIXTURES === "1" && repo.fullName === DEMO_REPO; + // Reviews and analyses always need the model; test replays may run without it. + if (!anthropicApiKey && !keyOptional && job.kind !== "test") throw new Error(NO_KEY); + + let fixture: (typeof FIXTURES)[string] | undefined; + if (job.row.fixture) { + if (process.env.CLAUDIO_DEV_FIXTURES !== "1") throw new Error("Fixture-Runs sind nur mit CLAUDIO_DEV_FIXTURES=1 erlaubt."); + fixture = FIXTURES[job.row.fixture]; + if (!fixture) throw new Error(`Unbekannte Fixture "${job.row.fixture}".`); + } + + return { + emit, + after, + flush: () => pending, + abort, + stop: () => { + clearInterval(abortPoll); + clearTimeout(hardAbort); + runningStops.delete(stop); + }, + stopped: () => stop.signal.aborted || abort.signal.aborted, + common: { + runsRoot, + projectRoot, + config, + roleEnv, + githubToken, + abortController: abort, + stopSignal: stop.signal, + anthropicApiKey, + requireApiKey: !keyOptional, + onProgress: emit, + human: humanBridge(db, { kind: job.kind, id: job.row.id, repoId: job.row.repoId }, emit, stop.signal), + }, + knowledge, + fixture, + }; +} + +async function execute(db: Db, job: Job): Promise { + let ctx: Awaited> | undefined; + // Set once the runner has created the run directory; its screenshots are stored after the run. + let run: RunHandle | undefined; + const finish = (status: ReviewStatus, error: string | null) => + job.kind === "review" + ? finishReview(db, job.row.id, { status, report: null, verdict: null, error }) + : job.kind === "analysis" + ? finishAnalysis(db, job.row.id, { status, report: null, error }) + : finishTestRun(db, job.row.id, { status, report: null, error }); + try { + ctx = await prepare(db, job); + const c = ctx; + if (job.kind === "review") { + if (c.knowledge) c.emit(`Wissensbasis von Commit ${c.knowledge.commitSha.slice(0, 7)} wird mitgegeben.`); + // The comment is posted by finalizeReview once the regression tests are done, so it can include them. + const report = await runReview({ ...job.row.request, postComment: false }, { + ...c.common, + knowledge: c.knowledge, + reportUrl: publicBaseUrl ? `${publicBaseUrl}/reviews/${job.row.id}` : undefined, + prContextFile: c.fixture?.prContextFile, + repoDir: c.fixture?.repoDir, + onRunStarted: (h: RunHandle) => { + run = h; + c.after(() => setReviewRunId(db, job.row.id, h.runId)); + }, + }); + await c.flush(); + await finishReview(db, job.row.id, { + status: report.status, + report, + verdict: verdict(report), + error: report.errors.length ? report.errors.join("\n") : null, + }); + log(`review ${job.row.id} finished: ${report.status}, ${report.cost.usd.toFixed(2)} USD`); + await finalizeReview(db, job.row.id, publicBaseUrl, log); + } else if (job.kind === "test") { + const row = await getTestScript(db, job.row.scriptId); + if (!row) throw new Error("Der Test existiert nicht mehr."); + const script = toTestScript(row); + const report = await runTest(script, job.row.request, { + ...c.common, + knowledge: c.knowledge, + onRunStarted: (h: RunHandle) => { + run = h; + c.after(() => setTestRunStarted(db, job.row.id, h.runId, script.steps?.length && !job.row.request.rerecord ? row.stepsVersion : 0)); + }, + }); + await c.flush(); + await finishTestRun(db, job.row.id, { status: report.status, report, error: report.errors.length ? report.errors.join("\n") : null }); + // A new recording or a healed replay becomes the script's next version. + if (report.compiledSteps?.length) { + // A new recording brings its own classification; a healed replay keeps the script's. + const determinism = report.mode === "record" ? (report.determinismProposal ?? "deterministic") : undefined; + await saveTestSteps(db, script.id, report.compiledSteps, determinism); + c.emit(`Ablauf gespeichert (${report.compiledSteps.length} Schritte, Version ${row.stepsVersion + 1}).`); + await c.flush(); + } + log(`test run ${job.row.id} (${script.name}) finished: ${report.outcome}, ${report.mode}, ${report.cost.usd.toFixed(2)} USD`); + if (job.row.reviewId) await finalizeReview(db, job.row.reviewId, publicBaseUrl, log); + } else { + if (c.knowledge) c.emit(`Aktualisiert die Wissensbasis von Commit ${c.knowledge.commitSha.slice(0, 7)}.`); + const report = await runAnalysis(job.row.request, { + ...c.common, + previous: c.knowledge, + repoDir: c.fixture?.repoDir, + commitSha: c.fixture ? "demo" : undefined, + onRunStarted: (h: RunHandle) => { + run = h; + c.after(() => setAnalysisRunId(db, job.row.id, h.runId)); + }, + }); + await c.flush(); + await finishAnalysis(db, job.row.id, { + status: report.status, + report, + error: report.errors.length ? report.errors.join("\n") : null, + }); + log(`analysis ${job.row.id} finished: ${report.status}, ${report.docs.length} docs, ${report.cost.usd.toFixed(2)} USD`); + } + } catch (e) { + const message = (e as Error).message; + ctx?.emit(`Fehler: ${message}`); + await ctx?.flush(); + await finish(ctx?.stopped() ? "aborted" : "failed", message); + log(`${job.kind} ${job.row.id} failed: ${message}`); + const reviewId = job.kind === "review" ? job.row.id : job.kind === "test" ? job.row.reviewId : null; + if (reviewId) await finalizeReview(db, reviewId, publicBaseUrl, log).catch(() => undefined); + } finally { + ctx?.stop(); + if (run) await persistArtifacts(db, run, log).catch((e) => log(`storing screenshots failed: ${(e as Error).message}`)); + } +} + +/** Reviews first: they block a merge, an analysis can wait. */ +async function claimNext(db: Db): Promise { + const review = await claimNextReview(db); + if (review) return { kind: "review", row: review }; + const test = await claimNextTestRun(db); + if (test) return { kind: "test", row: test }; + const analysis = await claimNextAnalysis(db); + if (analysis) return { kind: "analysis", row: analysis }; + return null; +} + +async function main(): Promise { + const db = getDb(); + const stale = await failStaleRuns(db); + if (stale) log(`marked ${stale} interrupted run(s) as failed`); + log(`polling for reviews and analyses (concurrency ${concurrency}, runs in ${runsRoot})`); + + const active = new Set>(); + let stopping = false; + const stop = (signal: string) => { + if (stopping) process.exit(1); + stopping = true; + // On SIGTERM (Kubernetes deploy) running agents are asked to finish now, so their cost is still + // recorded; the pod's termination grace period covers that. Ctrl+C locally lets them finish. + if (signal === "SIGTERM") for (const s of runningStops) s.abort(); + log(signal === "SIGTERM" ? `stopping ${runningStops.size} running run(s) for shutdown` : "stopping after the current run(s); press Ctrl+C again to force"); + }; + process.on("SIGINT", () => stop("SIGINT")); + process.on("SIGTERM", () => stop("SIGTERM")); + + const housekeeping = async () => { + await purgeOldRuns(runsRoot, retentionDays, log); + await purgeOldArtifacts(db, artifactRetentionDays, log); + }; + await housekeeping(); + const purge = setInterval(() => void housekeeping(), 6 * 3_600_000); + + while (!stopping) { + if (active.size < concurrency) { + const job = await claimNext(db).catch((e) => { + log(`poll failed: ${(e as Error).message}`); + return null; + }); + if (job) { + const what = job.kind === "review" ? `${job.row.request.repo}#${job.row.prNumber}` : job.kind === "test" ? `test script ${job.row.scriptId}` : job.row.request.repo; + log(`${job.kind} ${job.row.id}: ${what} → ${job.row.instanceUrl}`); + const run = execute(db, job).finally(() => active.delete(run)); + active.add(run); + continue; + } + } + await new Promise((r) => setTimeout(r, pollMs)); + } + await Promise.all(active); + clearInterval(purge); + await closeDb(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/worker/src/retention.ts b/apps/worker/src/retention.ts new file mode 100644 index 0000000..8884114 --- /dev/null +++ b/apps/worker/src/retention.ts @@ -0,0 +1,20 @@ +import { readdir, rm, stat } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Deletes run directories (screenshots, traces, live frames) older than the + * retention period. Reports, findings and costs stay in the database. + */ +export async function purgeOldRuns(runsRoot: string, days: number, log: (msg: string) => void): Promise { + if (!(days > 0)) return; + const cutoff = Date.now() - days * 86_400_000; + let removed = 0; + for (const name of await readdir(runsRoot).catch(() => [] as string[])) { + if (name.startsWith(".")) continue; + const dir = join(runsRoot, name); + const info = await stat(dir).catch(() => null); + if (!info?.isDirectory() || info.mtimeMs > cutoff) continue; + await rm(dir, { recursive: true, force: true }).then(() => removed++).catch(() => undefined); + } + if (removed) log(`removed ${removed} run director${removed === 1 ? "y" : "ies"} older than ${days} days`); +} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/apps/worker/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/claudio.config.example.json b/claudio.config.example.json new file mode 100644 index 0000000..3b62694 --- /dev/null +++ b/claudio.config.example.json @@ -0,0 +1,26 @@ +{ + "repo": "owner/name", + "notes": "Dispositions-Software für Schüler- und Personenbeförderung. Hauptobjekte: Fahrten, Touren, Fahrgäste, Fahrzeuge, Fahrer. Datumsfelder erwarten TT.MM.JJJJ.", + "extraAllowedHosts": [], + "roles": { + "admin": { + "description": "Vollzugriff, Mandantenverwaltung", + "usernameEnv": "CLAUDIO_ROLE_ADMIN_USER", + "passwordEnv": "CLAUDIO_ROLE_ADMIN_PASSWORD", + "loginPath": "/login", + "successUrlContains": "/dashboard" + }, + "dispatcher": { + "description": "Disponent: plant Fahrten und Touren", + "usernameEnv": "CLAUDIO_ROLE_DISPATCHER_USER", + "passwordEnv": "CLAUDIO_ROLE_DISPATCHER_PASSWORD", + "recipe": [ + { "action": "goto", "url": "/login" }, + { "action": "fill", "selector": "input[name=email]", "value": "$USER" }, + { "action": "fill", "selector": "input[name=password]", "value": "$PASSWORD" }, + { "action": "click", "selector": "button[type=submit]" }, + { "action": "waitForUrl", "pattern": "/dashboard" } + ] + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a10d0be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +# MySQL for the Claudio web UI and worker (development). +services: + db: + image: mysql:8.4 + restart: unless-stopped + environment: + MYSQL_DATABASE: claudio + MYSQL_USER: claudio + MYSQL_PASSWORD: claudio + MYSQL_ROOT_PASSWORD: claudio-root + ports: + - "${CLAUDIO_DB_PORT:-3310}:3306" + volumes: + - claudio-db:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uclaudio", "-pclaudio"] + interval: 5s + retries: 20 + +volumes: + claudio-db: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e7074fa --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,157 @@ +# Claudio: Plan für den automatisierten User-Review-Agenten + +> Stand: Phase 0 umgesetzt (CLI `claudio review`, Playwright-Treiber, Claudio-Tools, Demo-Target). Phase 1 und der Review-Teil von Phase 5 umgesetzt: `packages/db` (MySQL/Drizzle, AES-GCM-Secrets), `apps/worker` (Job-Polling, Abbruch, Live-Events), `apps/web` (SvelteKit 3: GitHub-OAuth-Login, Repos aus GitHub, Rollen, Review starten, Live-Verlauf per SSE, Report mit Evidenz, Findings bestätigen/verwerfen, Strenge nachträglich). Abweichung: Login über eine GitHub **OAuth App** statt GitHub App; Reviews nutzen das Token der startenden Person. Phase 2 umgesetzt: Repo-Analyse mit `code-scanner`-Subagent und Browser-Rundgang durch den Orchestrator, Wissensbasis aus fünf Markdown-Dokumenten (overview, modules, routes, app-map, notes) pro Analyse in MySQL (`knowledge_docs`), inkrementelles Update über die GitHub-Compare-API, Veraltet-Anzeige gegen den Branch-Head, Wissensbasis in jedem Review (klein: im Prompt, groß: als Dateien). Abweichungen: kein Schreiben nach `.claudio/` im Zielrepo (nur DB und Run-Verzeichnis); der Rundgang läuft sequenziell im Orchestrator statt mit parallelen `tester`-Agents, weil sich alle Agents eines Runs einen Browser teilen; Aktualisierung bei Merge auf main noch manuell (Webhook folgt mit Phase 5). Phase 3 umgesetzt: drei Prüf-Subagenten (`ac-verifier`, `regression-tester`, `ux-reviewer`) ab Budget standard, sequenziell wegen des gemeinsamen Browsers; Live-Vorschau per CDP-Screencast (`runDir/live.jpg`, in der UI als MJPEG); PR-Kommentar mit Link auf den Review, nachträglich aus der UI mit gewählter Strenge und ohne verworfene Findings; Markdown-Bericht in jeder Ausführlichkeit. Phase 4 umgesetzt: Test-Scripts (`test_scripts`, `test_runs`) mit eigener Schritt-DSL in `core/testing.ts`; Aufzeichnung über das Claudio-Tool `test_step` (Claudio führt jeden Schritt selbst mit Playwright aus, das Modell bekommt nur `snapshot`, den Accessibility-Tree), Replay ohne Modell, Selbstheilung ab dem gebrochenen Schritt mit neuer Version und Finding, `judge` als eigene SDK-Session mit Structured Output, Determinismus-Grad als Vorschlag der Aufzeichnung, Läufe einzeln, als Suite oder mit PR-Reviews, Vergleich zweier Läufe. Abweichung vom Plan: kein Playwright-MCP in Testläufen, damit Aufzeichnung und Replay garantiert dieselben Aktionen ausführen. Phase 5 umgesetzt: Repo-Webhook (per Klick über die GitHub-API angelegt, HMAC-Secret pro Repo, verschlüsselt), Auslöser Kommentar `/claudio review`, Label `claudio-review` und automatisch bei PR-Push (je Repo schaltbar), Wissensbasis-Update bei Push auf den Default-Branch, URL-Vorlagen pro Repo, Commit-Status „Claudio“ (pending, dann success/failure/error inkl. Regressionstests), PR-Kommentar nach Abschluss der Tests, Lernen aus verworfenen Findings (Lektionen pro Repo im Prompt). Abweichung: kein eigener Bot-Account; automatische Läufe nutzen das Token der Person, die den Webhook eingerichtet hat. Offen: Chrome-Live-Modus über das SDK, Produktionsbetrieb (Server, HTTPS). + +## Kontext + +Stadt.Land.Netz prüft Feature-Branches heute manuell: ein Reviewer liest das Ticket, verifiziert die Acceptance Criteria im Browser, bewertet UX/UI, schaut sich den geänderten Code an, leitet daraus betroffene Stellen ab und testet diese auf Regressionen. Claudio soll diesen Prozess weitgehend automatisieren. Das Repo `vanderlars/claudio` ist leer (nur README), wir starten auf der grünen Wiese. + +## Getroffene Entscheidungen + +| Thema | Entscheidung | +|---|---| +| Laufzeit | Lokal / eigene Server, **Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk`, TypeScript). Managed Agents vorerst nicht. | +| Anthropic-Auth | API-Key aus der Console für unbeaufsichtigte Runs (Kosten pro Run messbar). Der Live-Modus mit Chrome-Extension braucht laut Doku ein Abo-Login (Pro/Max/Team/Enterprise) auf dem Entwickler-Rechner. Beides wird unterstützt. | +| Browser | **Zwei Treiber hinter einer Abstraktion.** (1) **Playwright MCP** (`@playwright/mcp`) für unbeaufsichtigte Runs: headless, isoliert, Accessibility-Snapshots, Console, Network, Screenshots, Tracing, Storage-State. Optional headed mit Live-Vorschau in der Claudio-UI. (2) **Claude-Chrome-Extension** als **Live-Modus**: läuft über Claude Code auf dem Entwickler-Rechner in sichtbarem Chrome, man kann zusehen und eingreifen. Beide nutzen dieselben Agent-Definitionen, Skills und Claudio-Tools. | +| Stack | TypeScript-Monorepo (pnpm), SvelteKit für UI + API, MySQL (Drizzle ORM), ein Worker-Prozess für Runs. | +| Zielapps | Beliebige Webapps, zuerst SvelteKit. Login per Secrets, Rechte über App-Rollen. | +| PR-Instanz | URL wird beim Trigger angegeben (Pflichtfeld). URL-Templates pro Repo als spätere Erweiterung. | +| Testdaten | Keine Annahmen über Testdaten. Agent legt benötigte Daten selbst an, markiert sie (`claudio-`) und dokumentiert sie. | +| Ticket-Quelle | GitHub Issues (Verknüpfung über PR-Body, `closes #123`, Branch-Name). | +| Reports | Eigene Oberfläche. Im PR nur ein Kommentar mit TL;DR und Link. | +| Budget | Option pro Run (Stufen), nicht fest. | +| "Gleiches Ergebnis" | Pro Script wird beim ersten Run ein Determinismus-Grad festgelegt (deterministisch / semi / explorativ). Verglichen werden Funktionsergebnisse, nicht Pixel. | + +## Architektur + +### Monorepo-Struktur + +``` +claudio/ + apps/ + web/ SvelteKit: UI + API-Routen (Repos, Runs, Reports, Trigger, Secrets, Settings) + worker/ Run-Executor: pollt Jobs aus MySQL, startet Agent-Runs, schreibt Ergebnisse + packages/ + db/ Drizzle-Schema + Migrationen (MySQL), Repository-Funktionen + core/ Domänentypen, Finding/Expectation-Schemas (zod), Budget-Stufen, Report-Rendering + agent/ Agent-SDK-Runner, Hooks, Kostenzähler, Treiber-Auswahl (playwright | chrome) + browser/ Browser-Treiber: Playwright-MCP-Wiring, Chrome-Extension-Launcher, Claudio-Tools + (login_as, evidence, network/console) als eigenständiger stdio-MCP-Server + .claude/ + agents/ Subagent-Definitionen als Markdown (von SDK und Claude Code CLI gleichermaßen geladen) + skills/ Review-, Explorer-, Script-Skills (dito) + .mcp.json Claudio-Tool-Server + Playwright für den CLI-Live-Modus + github/ GitHub-App-Client (Octokit): PR-Diff, Issues, Kommentare, Webhook-Verifikation + knowledge/ Lese-/Schreibzugriff auf die Repo-Wissensbasis (.claudio/ im Zielrepo + DB-Spiegel) + docs/ + ARCHITECTURE.md +``` + +### Domänenmodell (MySQL) + +- **Repo**: GitHub-Owner/Name, Installation-ID, Default-Branch, Status der Analyse. +- **RepoSecret**: Name, verschlüsselter Wert (AES-GCM, Key aus Env), Rolle (z.B. `admin`, `dispatcher`). Werte verlassen den Worker nie Richtung Modell. +- **KnowledgeBase** (pro Repo, versioniert nach Commit): Code-Notizen, Modulkarte, Index Route/Screen zu Dateien, App-Map (Screens, Flows, Rollen, Soll-Verhalten), Agent-Notizen aus früheren Runs. +- **Review**: PR-Nummer, Branch, Instanz-URL, Verbosity, Strictness, Budget-Stufe, Status, Kosten, TL;DR. +- **TestScript**: Name, NL-Beschreibung, strukturierte Erwartung, Determinismus-Grad, kompilierte Schritte (Cache), Rollen. +- **Run**: Typ (analysis / review / script), Bezug, Status, Logs, Kosten, Session-ID des SDK. +- **Finding**: Run, Kategorie (AC, Regression, UX, UI, Console, Network, Security), Severity, Titel, Beschreibung, Schritte, Status (vorgeschlagen / bestätigt / verworfen). +- **Evidence**: Finding oder Run, Typ (Screenshot, Trace, HAR-Ausschnitt, Console-Dump), Dateipfad. + +Findings werden immer vollständig gespeichert. **Strictness** ist ein Severity-Schwellenwert (welche Findings blockieren und in den TL;DR kommen). **Verbosity** steuert nur das Rendering des Reports. Beides ist nachträglich umschaltbar. + +### Agenten (Agent SDK `agents`-Option, ein `query()` pro Run) + +| Agent | Modell | Aufgabe | +|---|---|---| +| Orchestrator (Haupt-Query) | `claude-opus-5-5` | Plant, delegiert, fasst zusammen. | +| `code-scanner` | Sonnet 5.5 | Liest das Zielrepo (Read/Glob/Grep), schreibt Modulkarte und Routen-Index. | +| `explorer` | Sonnet 5.5 | Klickt durch die App, baut die Todo-Liste der Screens/Funktionen in einer Datei. | +| `tester` | Sonnet 5.5 | Arbeitet Todo-Einträge ab, dokumentiert Soll-Verhalten (App-Map). Mehrfach parallel spawnbar. | +| `impact-analyst` | Opus 5.5 | Diff + Routen-Index → betroffene Screens und Flows. | +| `ac-verifier` | Opus 5.5 | Prüft jedes Acceptance Criterion im Browser, mit Evidenz. | +| `regression-tester` | Sonnet 5.5 | Prüft betroffene Screens gegen App-Map-Soll. | +| `ux-reviewer` | Opus 5.5 | Konsistenz, Usability, Verbesserungsvorschläge auf Screenshot-Basis. | +| `judge` | Opus 5.5, Structured Output | Vergleicht Script-Ergebnis gegen Erwartung, unabhängig vom Tester-Kontext. | +| `reporter` | Sonnet 5.5, read-only | Erzeugt Report und TL;DR aus den strukturierten Findings. | + +Budget-Stufen (Option pro Run) setzen Modell-Tier der Worker, `effort`, `maxTurns`, maximale Browser-Schritte und ein Kosten-Limit. Der Worker summiert `usage` aus den SDK-Messages und bricht bei Überschreitung sauber ab (Teilreport bleibt erhalten). + +### Browser-Schicht (`packages/browser`) + +**Grundsatz:** Agent-Definitionen, Skills und Prompts sind treiberneutral ("nutze die verfügbaren Browser-Tools"). Sie liegen als Dateien in `.claude/agents` und `.claude/skills`, damit das Agent SDK (über `settingSources`) und die Claude Code CLI dieselben Definitionen laden. Die Claudio-Tools laufen als eigenständiger stdio-MCP-Server, den beide Wege einbinden. + +**Treiber 1: Playwright (unbeaufsichtigt, Standard)** +- Playwright MCP per stdio: `--isolated --storage-state /state.json`, standardmäßig `--headless`. Ein Browser-Profil pro Run, keine geteilten Profile. +- Option "headed": sichtbares Fenster auf dem Worker-Rechner. Zusätzlich Live-Vorschau in der Claudio-UI: der Worker streamt den jeweils letzten Screenshot und den aktuellen Agent-Schritt per SSE. So kann man von überall zusehen, ohne Extension. +- `disallowedTools: ["mcp__playwright__browser_run_code_unsafe"]`. +- `PreToolUse`-Hook auf `^mcp__playwright__`: Host-Allowlist (nur Instanz-URL des Runs), kein `javascript:`-Navigieren, Schrittzähler fürs Budget. + +**Treiber 2: Chrome-Extension (Live-Modus zum Zusehen und Eingreifen)** +- Läuft auf dem Entwickler-Rechner. Claudio erzeugt einen Run-Auftrag (Prompt, Kontext, Run-ID) und startet Claude Code mit Chrome-Integration gegen das Claudio-Projekt (`.claude/`, `.mcp.json`). Der Entwickler sieht die Aktionen live in Chrome und kann pausieren oder selbst eingreifen (z.B. Login, CAPTCHA). +- Ergebnisse (Findings, Evidence) fließen über die Claudio-Tools zurück in die DB, so dass der Report identisch aussieht. +- Einschränkungen laut Doku: nur sichtbares Chrome, kein WSL, Abo-Login statt API-Key, Network-Logs nicht dokumentiert. Findings aus dem Live-Modus werden entsprechend gekennzeichnet. +- Spike-Punkt: ob das SDK den Chrome-Modus über `extraArgs` durchreicht. Falls nicht, startet der Worker die CLI als Kindprozess und liest die Ergebnisse über den Claudio-MCP-Server und die Session-Transkripte. + +**Claudio-Tools (eigener MCP-Server, von beiden Treibern genutzt):** + - `login_as(role)`: Playwright-Treiber: führt ein pro Repo gespeichertes Login-Rezept aus (Playwright direkt, nicht über das Modell), schreibt Storage-State, liefert nur "eingeloggt als Rolle X" zurück. Beim ersten Mal lässt der Agent sich den Login-Flow zeigen und speichert das Rezept, Credentials bleiben im Worker. Chrome-Treiber: nutzt die Login-Session des Entwicklers; auf Login-Seiten pausiert der Agent, der Mensch loggt ein. + - `capture_evidence(label)`: Screenshot + aktueller Snapshot in das Run-Verzeichnis, gibt Evidence-ID zurück. + - `get_console(filter)`, `get_network(filter)`: gefilterte Sichten auf Console und Requests (Fehler, 4xx/5xx, bestimmte Endpunkte), gekürzt, damit der Kontext klein bleibt. + - `assert_request(pattern, expectation)`: deterministische Prüfung eines Netzwerk-Calls. + - `note(text)` / `todo_add` / `todo_next`: strukturiertes Arbeitsgedächtnis für Explorer/Tester (Datei im Run-Verzeichnis). +- Nach jedem Run: Playwright-Trace und HAR ins Run-Verzeichnis, verknüpft mit Findings. + +### Pipelines + +1. **Repo-Analyse** (manuell gestartet, später bei Merge auf main): Zielrepo klonen (flach) → `code-scanner` → `explorer` baut Todo → mehrere `tester` arbeiten ab → Orchestrator schreibt App-Map und Notizen. Ergebnis landet in `.claudio/` (Markdown, diffbar) und als Spiegel in der DB. +2. **PR-Review** (Trigger aus UI mit PR-Nummer + Instanz-URL + Optionen): PR-Diff und verknüpfte Issues laden → AC extrahieren → `impact-analyst` → parallel `ac-verifier`, `regression-tester`, `ux-reviewer` → `reporter` → Findings + Evidence speichern → TL;DR-Kommentar in den PR. +3. **Test-Scripts**: NL-Script + Erwartung anlegen. Erster Run: Tester führt aus, dokumentiert Ablauf, speichert kompilierte Schritte und schlägt Determinismus-Grad vor. Folgeruns: deterministische Schritte laufen ohne Modell (Playwright-Replay), Modell greift nur ein, wenn ein Schritt bricht (self-healing, wird als Finding dokumentiert). `judge` bewertet das Ergebnis gegen die Erwartung mit Structured Output (`passed`, `deviations[]`, `confidence`). + +### GitHub-Anbindung + +GitHub App (Installation pro Org): PR lesen, Issues lesen, Kommentar schreiben, Repo klonen. Webhook für `pull_request`-Events vorbereitet, aber Reviews bleiben vorerst manuell. Bot-Reviewer-Trigger (`review_requested` für den Bot) als Phase 5. + +### Oberfläche (SvelteKit) + +Repos verwalten, Secrets (Rolle → Credentials), Analyse starten und Wissensbasis einsehen, Review starten (PR, URL, Verbosity, Strictness, Budget), Run-Status live (SSE), Report mit Findings, Evidenz und Bestätigen/Verwerfen, Test-Scripts anlegen und Runs vergleichen. + +## Phasen + +### Phase 0: Spike (zuerst, ohne UI) +Ziel: Machbarkeit, Qualität und Kosten pro Review messen. +- Monorepo-Gerüst (pnpm, TypeScript, `packages/agent`, `packages/browser`). +- CLI-Skript `claudio review --repo --pr --url --role --driver playwright|chrome`: lädt PR + Issue, startet eine Agent-Session mit dem gewählten Treiber, `login_as`, `capture_evidence`, schreibt Report als Markdown in `runs//`. +- Ergebnis: ein echter PR aus eurem Hauptprodukt mit beiden Treibern reviewt, Kosten und Dauer protokolliert. +- Nebenbei prüfen: parallele `query()`-Aufrufe im selben Prozess (laut Doku nicht spezifiziert), sonst ein Prozess pro Run. Und: läuft der Chrome-Modus über das SDK (`extraArgs`) oder nur über die CLI als Kindprozess? + +### Phase 1: Fundament +`packages/db` (Schema, Migrationen), `apps/worker` (Job-Polling, Run-Verzeichnisse, Kostenzähler, Budget-Abbruch), Secrets-Verschlüsselung, GitHub-App-Client. + +### Phase 2: Repo-Analyse +`code-scanner`, `explorer`/`tester`-Paar, App-Map-Format, `.claudio/`-Ablage, Aktualisierung bei neuem Commit auf main. + +### Phase 3: PR-Review +Impact-Analyse über Routen-Index, AC-Extraktion, drei Prüfagenten, Findings/Evidence-Modell, Verbosity/Strictness, TL;DR-Kommentar. + +### Phase 4: Test-Scripts +Erwartungs-Schema, erster Run mit Dokumentation, kompilierte Schritte, Replay, self-healing, `judge`, Run-Vergleich. + +### Phase 5: Oberfläche und Bot +SvelteKit-UI für alles oben, Live-Status, Bestätigen/Verwerfen von Findings mit Rückfluss in die Notizen, Bot-Reviewer per Webhook, URL-Templates pro Repo. + +## Verifikation + +- Phase 0: ein Review gegen eine reale Instanz, manuell geprüft: stimmen AC-Bewertung, betroffene Screens, Findings? Kosten unter der gewählten Budget-Stufe? +- Unit-Tests (vitest) für Schemas, Budget-Logik, Finding-Filter, Login-Rezept-Replay. +- Integrationstest mit einer kleinen SvelteKit-Demo-App im Repo (`apps/demo-target`) mit Login, Rollen und absichtlichen Bugs: Analyse, Review und ein Script müssen die eingebauten Bugs finden. Dient auch als Regressionssuite für Claudio selbst. +- Jeder Run hinterlässt Trace, HAR, Console-Dump und SDK-Session-ID, damit ein Mensch nachvollziehen kann, was der Agent getan hat. + +## Risiken und offene Punkte + +- Parallelität von `query()` im selben Prozess ist nicht dokumentiert. Fallback: Kindprozess pro Run. +- Chrome-Extension ist für CLI und VS Code dokumentiert, nicht für das SDK. Der Live-Modus ist daher an einen Entwickler-Rechner mit Abo-Login gebunden und läuft nicht parallel. Für Zusehen ohne diese Einschränkung gibt es die Live-Vorschau des Playwright-Treibers. +- Zwei Treiber bedeuten zwei Tool-Vokabulare. Prompts bleiben treiberneutral; treiberspezifische Hinweise kommen aus einem kleinen Präfix, das der Runner je nach Treiber einfügt. +- Login-Flows mit 2FA oder CAPTCHA lassen sich nicht automatisieren. Secrets-Modell sieht pro Rolle optional einen vorab erzeugten Storage-State vor. +- Datenschutz: Snapshots und Screenshots gehen an Anthropic. Nur Instanzen mit synthetischen Daten reviewen. Wird in der UI pro Repo als Bestätigung abgefragt. +- Testdaten-Adaptivität kostet Tokens: der Agent muss erst herausfinden, was existiert. Die App-Map aus der Analyse reduziert das ab dem zweiten Run. +- SvelteKit-Hydration und Client-Navigation: Snapshots müssen nach `networkidle` erfolgen. Playwright MCP wartet selbst, eigene Tools müssen es auch tun. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..8430334 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,73 @@ +# Deployment + +Claudio runs on Kubernetes, built and rolled out by GitHub Actions on every push to `main` (same setup as +`automation-runner`). + +``` +push to main ─► release.yml ─► ci (typecheck, tests incl. Playwright, migrations, build) + └► image (ghcr.io/vanderlars/claudio:sha-) + └► deploy (kubectl apply k8s/, rollout, diagnostics, auto-rollback) +pull request ─► ci.yml (same checks, no deploy) +``` + +## What runs where + +- **One image, two commands** (`Dockerfile`, based on Playwright's image with Chromium and git; Claude Code comes + with the Agent SDK): web UI `node apps/web/build`, worker `tsx apps/worker/src/main.ts`, migrations + `tsx packages/db/src/migrate.ts`. +- **One pod** (`k8s/deployment.yaml`) with the containers `web` and `worker` and the init container `migrate`. + Unlike automation-runner they share a pod: during a run the web UI shows the live frames and offers the trace the + worker writes into the shared run directory. That directory is an `emptyDir` – **no persistent volume**; nothing + on disk has to survive a deploy. One replica, `Recreate` on deploy. +- **All state in MySQL**, including screenshots: after each run the worker stores the evidence images in + `run_artifacts`; they are deleted after `CLAUDIO_ARTIFACT_RETENTION_DAYS` (14). Reports, findings and costs stay. + Traces and agent transcripts live only in the run directory and are gone after a deploy. +- **MySQL** outside the cluster (or `k8s/optional/mysql-statefulset.yaml` for staging). +- **Ingress** `k8s/ingress.yaml` with TLS via cert-manager. No basic auth: Claudio has its own GitHub login, and + `/webhooks/github` must be reachable for GitHub (signed per repository). +- **No server-wide Anthropic key.** Every repository stores its own key in the UI; runs without one do not start. + +## First-time setup (cluster admin, once) + +1. **Host name**: adjust `claudio.nova-hub.app` in `k8s/ingress.yaml` and `ORIGIN`/`PUBLIC_BASE_URL` in + `k8s/configmap.yaml`. Check `CLAUDIO_ALLOWED_GITHUB_ORGS` (who may sign in). +2. **GitHub OAuth App for production** (Settings → Developer settings → OAuth Apps): homepage + `https://`, callback `https:///auth/github/callback`. Leave "Expire user access tokens" either way; + Claudio renews expiring tokens. +3. **Database**: empty MySQL database with `DEFAULT CHARACTER SET utf8mb4`. The migrate init container needs + CREATE, ALTER, DROP, INDEX, REFERENCES – either for the app user, or a separate user as `DATABASE_MIGRATION_URL`. +4. **Namespace and secret**: + ```sh + kubectl apply -f k8s/namespace.yaml + ./tools/create-k8s-secret.sh # registry login, DATABASE_URL, OAuth app, generates CLAUDIO_SECRET_KEY + ``` + Back up `CLAUDIO_SECRET_KEY` (password manager): GitHub tokens, role credentials, webhook secrets and Anthropic + keys in the database are encrypted with it. +5. **Deploy credentials for GitHub Actions**: + ```sh + ./tools/create-deploy-kubeconfig.sh # ServiceAccount github-deployer (namespace-scoped, cannot read secrets) + # and repository secret KUBECONFIG_B64 + ``` +6. **First rollout**: push to `main` (or re-run the release workflow). The deploy job applies everything in `k8s/` + except `namespace.yaml` and the subfolders. + +## Every push to main + +`release.yml` runs the CI, pushes `ghcr.io/:sha-` (and `latest`), sets that tag in +`k8s/deployment.yaml`, applies the manifests and waits for the rollout. Pods that cannot start +(missing secret key, image pull, failing migration) fail the job within seconds with their logs in the output; +the deployment is then rolled back. Deploys never run in parallel. + +On a deploy the worker receives SIGTERM: running reviews, analyses and test runs are asked to stop (their cost is +still recorded) and end as "aborted"; queued ones wait for the new pod. + +## Operations + +- **Logs**: `kubectl -n claudio logs deploy/claudio -c worker` (or `-c web`, `-c migrate`). +- **Rollback by hand**: `kubectl -n claudio rollout undo deploy/claudio`. +- **Sizing**: one run = one Chromium + one Claude Code session (~0.5–1 GB). For more parallel runs raise + `CLAUDIO_WORKER_CONCURRENCY` in the ConfigMap together with the worker's memory limit. +- **Storage**: screenshots take roughly 0.5–2 MB per review in MySQL for 14 days. The run directory (`emptyDir`, + 10 GiB limit) is cleared on every deploy and after `CLAUDIO_RUN_RETENTION_DAYS` (14). +- **Image size**: ~3 GB, mostly the Playwright base image (it also contains Firefox and WebKit). A slimmer base + with only Chromium is possible later. diff --git a/k8s/admin/github-deployer.yaml b/k8s/admin/github-deployer.yaml new file mode 100644 index 0000000..ed6f180 --- /dev/null +++ b/k8s/admin/github-deployer.yaml @@ -0,0 +1,63 @@ +# One-time admin setup for the GitHub Actions deploy job (release.yml → job "deploy"). +# Lives outside `kubectl apply -f k8s/` on purpose – applied once by a cluster admin: +# kubectl apply -f k8s/admin/github-deployer.yaml +# ./tools/create-deploy-kubeconfig.sh # builds the kubeconfig and stores it as repository secret KUBECONFIG_B64 +# +# Least privilege: namespace-scoped, may manage the app's Deployment/Service/ConfigMap/Ingress and read +# pods, events and logs for diagnostics. It can NOT read Secrets or touch the namespace itself. +# Rotate the token: kubectl -n claudio delete secret github-deployer-token && re-apply this file && re-run the script. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: github-deployer + namespace: claudio +automountServiceAccountToken: false +--- +# Long-lived token for the ServiceAccount (populated by the token controller) +apiVersion: v1 +kind: Secret +metadata: + name: github-deployer-token + namespace: claudio + annotations: + kubernetes.io/service-account.name: github-deployer +type: kubernetes.io/service-account-token +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: github-deployer + namespace: claudio +rules: + - apiGroups: ['apps'] + resources: ['deployments'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch'] + - apiGroups: ['apps'] + resources: ['replicasets'] # rollout undo + verbs: ['get', 'list', 'watch'] + - apiGroups: [''] + resources: ['services', 'configmaps'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch'] + - apiGroups: ['networking.k8s.io'] + resources: ['ingresses'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch'] + - apiGroups: [''] + resources: ['pods', 'events'] # diagnostics on failed rollouts + verbs: ['get', 'list', 'watch'] + - apiGroups: [''] + resources: ['pods/log'] + verbs: ['get'] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: github-deployer + namespace: claudio +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: github-deployer +subjects: + - kind: ServiceAccount + name: github-deployer + namespace: claudio diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..5921a37 --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: claudio-config + namespace: claudio +data: + TZ: Europe/Berlin + # Public URL of the web UI: SvelteKit CSRF origin check, OAuth callback, links in PR comments, webhook target + ORIGIN: https://claudio.nova-hub.app + PUBLIC_BASE_URL: https://claudio.nova-hub.app + # Who may sign in (comma-separated GitHub organisations / users; empty = every GitHub user) + CLAUDIO_ALLOWED_GITHUB_ORGS: StadtLandNetz + CLAUDIO_ALLOWED_GITHUB_USERS: '' + # Runs at the same time. Each run starts a Chromium and a Claude Code session (~0.5–1 GB RAM); + # raise together with the worker's memory limit. + CLAUDIO_WORKER_CONCURRENCY: '1' + # Screenshots (stored in the database) older than this are deleted; reports stay + CLAUDIO_ARTIFACT_RETENTION_DAYS: '14' + # Run directories (traces, checkouts) older than this are deleted; the directory is ephemeral anyway + CLAUDIO_RUN_RETENTION_DAYS: '14' + # How long a run waits for a person to solve a CAPTCHA before giving up + CLAUDIO_HUMAN_TIMEOUT_MIN: '15' diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..bfeb3bb --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,100 @@ +# Web UI and worker in ONE pod: during a run the web UI shows the live frames and the trace the worker writes, +# so both share the run directory (an emptyDir – nothing on disk has to survive a deploy). Screenshots are stored +# in the database after each run and deleted after CLAUDIO_ARTIFACT_RETENTION_DAYS. One replica, one worker. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: claudio + namespace: claudio + labels: + app.kubernetes.io/name: claudio +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: claudio + template: + metadata: + labels: + app.kubernetes.io/name: claudio + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 # pwuser in the Playwright base image + runAsGroup: 1001 + fsGroup: 1001 # makes the run directory writable + # One secret for everything: image pull, database, encryption key, GitHub OAuth (see k8s/examples) + imagePullSecrets: + - name: claudio-secrets + # On deploy the worker asks running agents to stop (their cost is still recorded); give them time. + terminationGracePeriodSeconds: 120 + initContainers: + - name: migrate + image: ghcr.io/vanderlars/claudio:sha-0000000 # fallback for manual applies; the deploy job in release.yml sets the pushed commit's tag + command: ['node_modules/.bin/tsx', 'packages/db/src/migrate.ts'] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: { name: claudio-secrets, key: DATABASE_URL } + # Optional: a user WITH DDL privileges just for migrations (falls back to DATABASE_URL) + - name: DATABASE_MIGRATION_URL + valueFrom: + secretKeyRef: { name: claudio-secrets, key: DATABASE_MIGRATION_URL, optional: true } + containers: + - name: web + image: ghcr.io/vanderlars/claudio:sha-0000000 + command: ['node', 'apps/web/build'] + ports: + - name: http + containerPort: 3000 + env: &appEnv + - name: DATABASE_URL + valueFrom: + secretKeyRef: { name: claudio-secrets, key: DATABASE_URL } + - name: CLAUDIO_SECRET_KEY + valueFrom: + secretKeyRef: { name: claudio-secrets, key: CLAUDIO_SECRET_KEY } + - name: GITHUB_CLIENT_ID + valueFrom: + secretKeyRef: { name: claudio-secrets, key: GITHUB_CLIENT_ID } + - name: GITHUB_CLIENT_SECRET + valueFrom: + secretKeyRef: { name: claudio-secrets, key: GITHUB_CLIENT_SECRET } + envFrom: + - configMapRef: + name: claudio-config + volumeMounts: + - { name: runs, mountPath: /data } + readinessProbe: + httpGet: { path: /api/health, port: http } + periodSeconds: 10 + livenessProbe: + httpGet: { path: /api/health, port: http } + initialDelaySeconds: 15 + periodSeconds: 30 + resources: + requests: { cpu: 50m, memory: 192Mi } + limits: { memory: 512Mi } + - name: worker + image: ghcr.io/vanderlars/claudio:sha-0000000 + command: ['node_modules/.bin/tsx', 'apps/worker/src/main.ts'] + env: *appEnv + envFrom: + - configMapRef: + name: claudio-config + volumeMounts: + - { name: runs, mountPath: /data } + # Chromium needs more shared memory than the container default of 64 MB + - { name: dshm, mountPath: /dev/shm } + resources: + # One run = one Chromium + one Claude Code session; scale with CLAUDIO_WORKER_CONCURRENCY + requests: { cpu: 500m, memory: 1Gi } + limits: { memory: 3Gi } + volumes: + # Ephemeral run directories (live frames, traces, checkouts); screenshots are kept in the database + - name: runs + emptyDir: { sizeLimit: 10Gi } + - name: dshm + emptyDir: { medium: Memory, sizeLimit: 1Gi } diff --git a/k8s/examples/secret.example.yaml b/k8s/examples/secret.example.yaml new file mode 100644 index 0000000..906e7e1 --- /dev/null +++ b/k8s/examples/secret.example.yaml @@ -0,0 +1,28 @@ +# The ONE secret of this deployment – do NOT commit real values. Easiest: ./tools/create-k8s-secret.sh +# +# Keys: +# DATABASE_URL MySQL connection string for web + worker +# DATABASE_MIGRATION_URL optional: same database, a user WITH CREATE/ALTER/DROP/INDEX/REFERENCES – used only by +# the migrate init container +# CLAUDIO_SECRET_KEY encrypts GitHub tokens, role credentials, webhook secrets and Anthropic keys in the +# database (openssl rand -base64 32). Back it up: without it all of them are unreadable. +# GITHUB_CLIENT_ID GitHub OAuth App (callback: https:///auth/github/callback) +# GITHUB_CLIENT_SECRET +# .dockerconfigjson registry login for pulling the private GHCR image +# +# There is no server-wide Anthropic key on purpose: every repository carries its own (Repo → Einstellungen). +apiVersion: v1 +kind: Secret +metadata: + name: claudio-secrets + namespace: claudio +type: kubernetes.io/dockerconfigjson +stringData: + DATABASE_URL: mysql://claudio:CHANGE_ME@mysql.example.internal:3306/claudio + # optional – omit when the app user itself may run DDL + DATABASE_MIGRATION_URL: mysql://claudio_migrate:CHANGE_ME@mysql.example.internal:3306/claudio + CLAUDIO_SECRET_KEY: CHANGE_ME_openssl_rand_base64_32 + GITHUB_CLIENT_ID: CHANGE_ME + GITHUB_CLIENT_SECRET: CHANGE_ME + # {"auths":{"ghcr.io":{"auth":""}}} + .dockerconfigjson: '{"auths":{"ghcr.io":{"auth":"CHANGE_ME"}}}' diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..7daced2 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,31 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: claudio-web + namespace: claudio + annotations: + # No basic auth: Claudio has its own GitHub login, and /webhooks/github must stay reachable for GitHub + # (it authenticates every delivery with the repository's HMAC secret). + # Live log (SSE) needs long read timeouts and no buffering + nginx.ingress.kubernetes.io/proxy-read-timeout: '3600' + nginx.ingress.kubernetes.io/proxy-buffering: 'off' + # GitHub webhook payloads of large pull requests can exceed the 1 MB default + nginx.ingress.kubernetes.io/proxy-body-size: 25m + # TLS via cert-manager (ClusterIssuer exists in the cluster) + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: nginx + tls: + - hosts: [claudio.nova-hub.app] + secretName: claudio-web-tls + rules: + - host: claudio.nova-hub.app + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: claudio-web + port: + name: http diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..ac00e3a --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: claudio diff --git a/k8s/optional/mysql-statefulset.yaml b/k8s/optional/mysql-statefulset.yaml new file mode 100644 index 0000000..28efd7d --- /dev/null +++ b/k8s/optional/mysql-statefulset.yaml @@ -0,0 +1,71 @@ +# Optional: MySQL 8 in the cluster (staging/test only). Production uses the existing MySQL server. +# Apply: kubectl -n claudio apply -f k8s/optional/mysql-statefulset.yaml +# DATABASE_URL then: mysql://claudio:@mysql.claudio.svc:3306/claudio +apiVersion: v1 +kind: Secret +metadata: + name: mysql-credentials + namespace: claudio +type: Opaque +stringData: + MYSQL_ROOT_PASSWORD: CHANGE_ME_ROOT + MYSQL_DATABASE: claudio + MYSQL_USER: claudio + MYSQL_PASSWORD: CHANGE_ME +--- +apiVersion: v1 +kind: Service +metadata: + name: mysql + namespace: claudio +spec: + clusterIP: None + selector: + app.kubernetes.io/name: mysql + ports: + - port: 3306 + name: mysql +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mysql + namespace: claudio +spec: + serviceName: mysql + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: mysql + template: + metadata: + labels: + app.kubernetes.io/name: mysql + spec: + containers: + - name: mysql + image: mysql:8.4 + args: ['--character-set-server=utf8mb4', '--collation-server=utf8mb4_0900_ai_ci'] + envFrom: + - secretRef: + name: mysql-credentials + ports: + - containerPort: 3306 + name: mysql + volumeMounts: + - name: data + mountPath: /var/lib/mysql + resources: + requests: + cpu: 100m + memory: 512Mi + limits: + memory: 1Gi + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ['ReadWriteOnce'] + resources: + requests: + storage: 10Gi diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..dfb4c93 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: claudio-web + namespace: claudio +spec: + selector: + app.kubernetes.io/name: claudio + ports: + - name: http + port: 80 + targetPort: http diff --git a/package.json b/package.json new file mode 100644 index 0000000..82655a5 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "claudio", + "private": true, + "version": "0.1.0", + "description": "Agentic user-review and frontend test suite built on the Claude Agent SDK", + "packageManager": "pnpm@10.28.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "pnpm -r --filter './packages/*' --filter './apps/*' run build", + "typecheck": "pnpm -r run typecheck", + "test": "vitest run", + "claudio": "tsx apps/cli/src/main.ts", + "dev": "pnpm --parallel --filter @claudio/web --filter @claudio/worker run dev", + "web": "pnpm --filter @claudio/web run dev", + "worker": "pnpm --filter @claudio/worker run start", + "db:up": "docker compose up -d --wait", + "db:migrate": "pnpm --filter @claudio/db run migrate", + "db:generate": "pnpm --filter @claudio/db run generate", + "demo-target": "node apps/demo-target/src/server.mjs" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "tsx": "^4.23.15", + "typescript": "^5.9.3", + "vitest": "^5.0.3" + } +} diff --git a/packages/agent/package.json b/packages/agent/package.json new file mode 100644 index 0000000..4bd7a8a --- /dev/null +++ b/packages/agent/package.json @@ -0,0 +1,21 @@ +{ + "name": "@claudio/agent", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.296", + "@claudio/browser": "workspace:*", + "@claudio/core": "workspace:*", + "@claudio/github": "workspace:*", + "zod": "^4.6.5" + } +} diff --git a/packages/agent/src/analysis-prompt.ts b/packages/agent/src/analysis-prompt.ts new file mode 100644 index 0000000..6ebda5b --- /dev/null +++ b/packages/agent/src/analysis-prompt.ts @@ -0,0 +1,94 @@ +import type { AnalysisRequest, BudgetProfile, KnowledgeBase, RepoConfig } from "@claudio/core"; +import type { ChangedSince } from "@claudio/github"; +import { renderKnowledgeSection } from "./prompts.js"; + +export const ANALYSIS_SYSTEM_APPEND = ` +You are Claudio, building the knowledge base for a web application so that later automated reviews know how the app is built and how it is supposed to behave. You read the code, walk through the running app and write what you learn into a small set of Markdown documents with write_knowledge. + +Working rules: +- The documents are for agents and people who review pull requests later. Be concrete: URL paths, file paths, field names, button labels, validation messages, which role sees what. Skip generic statements that would be true of any app. +- Describe observed behaviour as the expected behaviour unless it is clearly a bug. Clear bugs (errors, HTTP 5xx, broken pages) go to report_finding and get a short mention in notes. +- Do not change data that other people rely on. Prefer reading over writing. When a flow can only be understood by submitting a form, create clearly synthetic records named "claudio--" and note each one. Never delete records, change settings, send messages or trigger payments. +- Never type credentials. Use login_as. +- CAPTCHAs and bot checks are for humans. Never try to solve one. If request_human_help is available, call it; otherwise record the blocked part and continue. +- Stay inside the instance under test. +- Write documents early and extend them as you go (write_knowledge with mode "append" for app-map), so a run that hits its budget still leaves a useful knowledge base. +`.trim(); + +export type AnalysisPromptInput = { + runId: string; + request: AnalysisRequest; + config: RepoConfig | null; + repoDir: string | null; + branch: string; + commitSha: string; + knowledgeDir: string; + previous: KnowledgeBase | null; + changedSince: ChangedSince | null; + profile: BudgetProfile; + driverPrefix: string; +}; + +export function buildAnalysisPrompt(input: AnalysisPromptInput): string { + const { request, config, repoDir, profile, previous, changedSince } = input; + const lang = request.language === "de" ? "German" : "English"; + const roles = Object.entries(config?.roles ?? {}); + const sections: string[] = []; + + sections.push(input.driverPrefix); + sections.push( + `# Analysis task\n\nRun id: ${input.runId}\nRepository: ${request.repo}, branch ${input.branch}, commit ${input.commitSha}\nWrite the documents in ${lang}; keep code identifiers, paths and UI labels verbatim.\nBudget: about ${profile.maxTurns} turns and ${profile.maxBrowserSteps} browser steps. ` + + (previous ? "This is an update: spend the budget on what changed and on screens the previous analysis did not cover." : "Cover the main screens and flows first; depth on rarely used admin screens comes last."), + ); + + sections.push( + repoDir + ? `# Repository checkout\n\nThe branch is checked out at ${repoDir}. Read it with Read/Grep/Glob. Delegate the full code scan to the code-scanner subagent so your own context stays free for the browser walk.` + : "# Repository checkout\n\nNo checkout is available. Build modules and routes from what the running app reveals (URLs, navigation, network requests) and say in overview.md that the code was not read.", + ); + + sections.push( + `# Instance\n\nBase URL: ${request.instanceUrl}\n` + + (roles.length + ? `Roles available via login_as: ${roles.map(([n, r]) => `${n}${r.description ? ` (${r.description})` : ""}`).join(", ")}.${request.roles.length ? ` Start with: ${request.roles.join(", ")}.` : ""} Visit the app with every role at least briefly to record which screens and actions each role has.` + : "No roles are configured. Explore what is reachable without login and note in overview.md that authenticated areas were not analysed."), + ); + + sections.push(`# Knowledge base directory\n\nwrite_knowledge stores the documents in ${input.knowledgeDir}. You may Read them there to check what you already wrote.`); + + if (previous) { + sections.push(renderKnowledgeSection(input.knowledgeDir, previous, "analysis")); + if (changedSince) { + const list = changedSince.files.slice(0, 300).map((f) => `- ${f.status} ${f.filename}`).join("\n"); + sections.push( + `# Changes since the previous analysis\n\n${changedSince.totalCommits} commits, ${changedSince.files.length} files${changedSince.truncated ? " (list truncated by GitHub)" : ""}:\n\n${list || "(none)"}\n\nUpdate modules.md and routes.md for these files, then re-check the screens they affect and update their app-map entries. Spot-check a few unaffected screens to confirm the rest still holds.`, + ); + } else { + sections.push("# Changes since the previous analysis\n\nThe list of changed files is not available. Re-check the code structure briefly, then verify the app map screen by screen and correct what changed."); + } + } + + if (config?.notes) sections.push(`# Notes about this application\n\n${config.notes.trim()}`); + if (request.extraInstructions) sections.push(`# Additional instructions for this run\n\n${request.extraInstructions.trim()}`); + + sections.push(`# Procedure + +1. Code: ${repoDir ? `ask the code-scanner subagent to map ${repoDir}${previous && changedSince ? " (focus on the changed files, but return the complete module and route lists)" : ""}. From its report` : "from the app itself"}, write modules.md and routes.md with write_knowledge, and a first version of overview.md. +2. Plan the walk: add one todo_add item per screen or flow from routes.md, ordered by importance for everyday users. Include role-specific areas. +3. Walk the app with todo_next / todo_done. For each screen: log in with a role that can see it, open it, take a snapshot, capture_evidence once for the main state, and try the main actions in a harmless way (open dialogs, sort, filter, submit an empty form to see validation). Check get_console and get_network for errors. Then append one section to app-map with write_knowledge (mode "append") in this format (translate the field labels into ${lang}): + + ## () + - Purpose: one sentence. + - Roles: who sees it, what differs per role. + - Elements: main lists, columns, fields (with required/optional and format), buttons. + - Actions and expected behaviour: one bullet per action, with the feedback the user gets (messages verbatim), validation rules, navigation after success. + - Code: route file and key components (from routes.md). + - Evidence: the evidence id. + +4. Record clear bugs you encounter with report_finding; do not go looking for bugs, that is the reviews' job. +5. Write notes.md: test data that exists and what you created, quirks, slow or flaky areas, what you could not explore and why. +6. Finalise overview.md: purpose, users, stack, domain objects, roles and permissions, login, navigation structure. +7. Finish with a short summary in ${lang} as your final message: what is covered, what is missing, and anything a reviewer should know.`); + + return sections.join("\n\n"); +} diff --git a/packages/agent/src/analysis-runner.ts b/packages/agent/src/analysis-runner.ts new file mode 100644 index 0000000..2e918a4 --- /dev/null +++ b/packages/agent/src/analysis-runner.ts @@ -0,0 +1,223 @@ +import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { query, type Options, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { RunStore, createClaudioTools, createDriver, startLiveView, type DriverSession, type HumanBridgeFactory } from "@claudio/browser"; +import { + AnalysisRequestSchema, + KNOWLEDGE_DOCS, + KnowledgeDocNameSchema, + analysisProfile, + loadRepoConfig, + newRunId, + nowIso, + type AnalysisReport, + type AnalysisRequest, + type KnowledgeBase, + type KnowledgeDoc, + type RunStatus, +} from "@claudio/core"; +import { changedFilesBetween, createGithubClient, getDefaultBranch, type ChangedSince } from "@claudio/github"; +import { ANALYSIS_SYSTEM_APPEND, buildAnalysisPrompt } from "./analysis-prompt.js"; +import { createBrowserGuard, createCaptchaHook, createStopHook } from "./hooks.js"; +import { cloneBranch, describe, envWithout, mapStatus, modelEnv, scrub, writeKnowledgeFiles, type RunnerDeps } from "./runner.js"; +import { createClaudioSdkServer } from "./sdk-tools.js"; + +export type AnalysisDeps = Omit & { + /** Knowledge base of the previous analysis; the run updates it instead of starting from scratch. */ + previous?: KnowledgeBase | null; + /** Commit sha to record when repoDir is given (no clone). */ + commitSha?: string; +}; + +export async function runAnalysis(input: AnalysisRequest, deps: AnalysisDeps = {}): Promise { + const request = AnalysisRequestSchema.parse(input); + const log = deps.onProgress ?? (() => undefined); + const runId = newRunId(`analysis-${request.repo.split("/")[1]}`); + const runDir = join(resolve(deps.runsRoot ?? "runs"), runId); + const store = new RunStore(runDir); + const knowledgeDir = join(runDir, "knowledge"); + const started = Date.now(); + const errors: string[] = []; + log(`run ${runId} → ${runDir}`); + deps.onRunStarted?.({ runId, runDir }); + + const config = deps.config !== undefined ? deps.config : loadRepoConfig(deps.configPath); + const roleEnvVars = Object.values(config?.roles ?? {}).flatMap((r) => [r.usernameEnv, r.passwordEnv]); + const token = deps.githubToken ?? process.env.GITHUB_TOKEN; + + // Checkout: an existing directory (demo, local) or a fresh clone of the branch. + let repoDir: string | null = null; + let branch = request.branch ?? ""; + let commitSha = deps.commitSha ?? "unknown"; + if (deps.repoDir) { + repoDir = resolve(deps.repoDir); + branch ||= "local"; + } else if (token) { + try { + const github = createGithubClient(token); + branch ||= await getDefaultBranch(github, request.repo); + repoDir = join(runDir, "repo"); + commitSha = await cloneBranch(request.repo, branch, repoDir, token); + log(`checked out ${branch} at ${commitSha.slice(0, 7)}`); + } catch (e) { + repoDir = null; + const msg = `clone failed: ${scrub((e as Error).message, token)}`; + errors.push(msg); + log(msg); + } + } else { + errors.push("No GitHub token: the code was not read."); + } + + // Update mode: start from the previous documents and the list of files changed since. + const previous = deps.previous?.docs.length ? deps.previous : null; + let changedSince: ChangedSince | null = null; + if (previous) { + writeKnowledgeFiles(knowledgeDir, previous); + if (token && !deps.repoDir && commitSha !== "unknown" && previous.commitSha !== commitSha) { + try { + changedSince = await changedFilesBetween(createGithubClient(token), request.repo, previous.commitSha, commitSha); + log(`update from ${previous.commitSha.slice(0, 7)}: ${changedSince.files.length} changed files`); + } catch (e) { + log(`compare failed: ${scrub((e as Error).message, token)}`); + } + } + } + + const profile = analysisProfile(request.budget); + const driver = createDriver("playwright"); + const session: DriverSession = await driver.start({ + runDir, + instanceUrl: request.instanceUrl, + headed: request.headed, + viewport: { width: 1280, height: 800 }, + language: request.language, + }); + log(`browser driver: ${session.name}`); + const human = session.attachment && deps.human ? deps.human(session.attachment) : undefined; + // Screencast for the live view in the web UI (runDir/live.jpg). + const liveView = session.attachment && deps.liveView !== false ? startLiveView(session.attachment, join(runDir, "live.jpg")) : null; + + const tools = createClaudioTools({ + runDir, + store, + attachment: session.attachment, + instanceUrl: request.instanceUrl, + roles: config?.roles ?? {}, + driver: session.name, + env: deps.roleEnv ? { ...process.env, ...deps.roleEnv } : undefined, + human, + knowledgeDir, + }); + + const guard = createBrowserGuard({ + allowedHosts: [new URL(request.instanceUrl).hostname, ...(config?.extraAllowedHosts ?? [])], + maxBrowserSteps: profile.maxBrowserSteps, + onStep: (n) => { + if (n % 25 === 0) log(`browser steps: ${n}/${profile.maxBrowserSteps}`); + }, + }); + + const prompt = buildAnalysisPrompt({ + runId, + request, + config, + repoDir, + branch, + commitSha, + knowledgeDir, + previous, + changedSince, + profile, + driverPrefix: session.promptPrefix, + }); + store.writeFile("prompt.md", prompt); + + const abortController = deps.abortController ?? new AbortController(); + const options: Options = { + cwd: resolve(deps.projectRoot ?? process.cwd()), + settingSources: ["project"], + systemPrompt: { type: "preset", preset: "claude_code", append: ANALYSIS_SYSTEM_APPEND }, + model: profile.orchestratorModel, + effort: profile.effort, + thinking: { type: "adaptive" }, + maxTurns: profile.maxTurns, + maxBudgetUsd: profile.maxBudgetUsd, + mcpServers: { ...session.mcpServers, claudio: createClaudioSdkServer(tools) }, + strictMcpConfig: true, + allowedTools: [...session.allowedTools, "Read", "Grep", "Glob", "Agent", "TodoWrite"], + disallowedTools: [...session.disallowedTools, "Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "KillShell", "BashOutput"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + hooks: { + PreToolUse: [...createStopHook(deps.stopSignal), ...guard.hooks], + PostToolUse: session.attachment ? [createCaptchaHook(() => session.attachment?.activePage(), Boolean(human))] : [], + }, + additionalDirectories: [repoDir, knowledgeDir].filter((d): d is string => Boolean(d)), + extraArgs: session.extraArgs, + abortController, + env: modelEnv(envWithout(process.env, roleEnvVars), deps.anthropicApiKey), + stderr: (data) => appendFileSync(join(runDir, "claude-stderr.log"), data), + }; + + const logFile = join(runDir, "log.jsonl"); + let result: SDKResultMessage | undefined; + let sessionId: string | undefined; + let status: RunStatus = "failed"; + try { + for await (const message of query({ prompt, options })) { + appendFileSync(logFile, JSON.stringify(message) + "\n"); + sessionId ??= (message as { session_id?: string }).session_id; + describe(message, log); + if (message.type === "result") result = message; + } + status = deps.stopSignal?.aborted ? "aborted" : mapStatus(result); + if (result && result.subtype !== "success") errors.push(...result.errors); + } catch (e) { + status = abortController.signal.aborted ? "aborted" : "failed"; + errors.push(`agent error: ${(e as Error).message}`); + log(`agent error: ${(e as Error).message}`); + } finally { + await liveView?.stop().catch(() => undefined); + await session.close().catch((e) => errors.push(`driver close: ${(e as Error).message}`)); + } + + const report: AnalysisReport = { + runId, + status, + request, + commitSha, + branch, + previousCommitSha: previous?.commitSha, + summary: result && result.subtype === "success" ? result.result : "", + docs: readKnowledgeFiles(knowledgeDir), + findings: store.findings(), + evidence: store.evidence(), + cost: { + usd: result?.total_cost_usd ?? 0, + inputTokens: (result?.usage.input_tokens ?? 0) + (result?.usage.cache_read_input_tokens ?? 0) + (result?.usage.cache_creation_input_tokens ?? 0), + outputTokens: result?.usage.output_tokens ?? 0, + turns: result?.num_turns ?? 0, + durationMs: Date.now() - started, + browserSteps: guard.steps(), + }, + sessionId, + errors, + createdAt: nowIso(), + }; + writeFileSync(join(runDir, "analysis.json"), JSON.stringify(report, null, 2)); + log(`analysis: status ${status}, ${report.docs.length} documents, ${report.cost.usd.toFixed(2)} USD`); + return report; +} + +/** Reads whatever knowledge documents exist in a directory, in the canonical order. */ +export function readKnowledgeFiles(dir: string): KnowledgeDoc[] { + const docs: KnowledgeDoc[] = []; + for (const name of KnowledgeDocNameSchema.options) { + const file = join(dir, KNOWLEDGE_DOCS[name].file); + if (!existsSync(file)) continue; + const content = readFileSync(file, "utf8").trim(); + if (content) docs.push({ name, content }); + } + return docs; +} diff --git a/packages/agent/src/hooks.ts b/packages/agent/src/hooks.ts new file mode 100644 index 0000000..6313bb6 --- /dev/null +++ b/packages/agent/src/hooks.ts @@ -0,0 +1,115 @@ +import type { HookCallback, HookCallbackMatcher, PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk"; +import { detectCaptcha, type BrowserAttachment } from "@claudio/browser"; + +export type BrowserGuardOptions = { + allowedHosts: string[]; + maxBrowserSteps: number; + onStep?: (count: number, toolName: string) => void; +}; + +export type BrowserGuard = { + hooks: HookCallbackMatcher[]; + steps(): number; +}; + +const NAVIGATION_TOOLS = new Set(["mcp__playwright__browser_navigate", "mcp__claude-in-chrome__navigate"]); + +/** + * PreToolUse guard for browser tools: keeps navigation on the allowed hosts, + * blocks javascript: URLs and enforces the budget's browser step limit. + */ +export function createBrowserGuard(opts: BrowserGuardOptions): BrowserGuard { + let steps = 0; + const allowed = opts.allowedHosts.map((h) => h.toLowerCase()); + + const guard: HookCallback = async (input) => { + const i = input as PreToolUseHookInput; + if (i.hook_event_name !== "PreToolUse") return {}; + const name = i.tool_name; + if (!name.startsWith("mcp__playwright__") && !name.includes("chrome")) return {}; + if (name.startsWith("mcp__claudio__")) return {}; + + steps += 1; + opts.onStep?.(steps, name); + if (steps > opts.maxBrowserSteps) { + return deny(`Browser step budget of ${opts.maxBrowserSteps} exhausted. Stop testing, record what you have with report_finding / record_ac_result, and write the final summary now.`); + } + + if (NAVIGATION_TOOLS.has(name)) { + const url = String((i.tool_input as { url?: string })?.url ?? ""); + const verdictMsg = checkUrl(url, allowed); + if (verdictMsg) return deny(verdictMsg); + } + return {}; + }; + + return { + hooks: [{ matcher: "^mcp__", hooks: [guard] }], + steps: () => steps, + }; +} + +function checkUrl(url: string, allowedHosts: string[]): string | null { + if (!url) return null; + if (/^javascript:/i.test(url)) return "javascript: URLs are not allowed."; + if (/^(about:blank|data:)/i.test(url)) return null; + let host: string; + try { + host = new URL(url).hostname.toLowerCase(); + } catch { + // Relative URLs resolve against the current page, which is already on an allowed host. + return null; + } + const ok = allowedHosts.some((h) => host === h || host.endsWith(`.${h}`)); + return ok ? null : `Navigation to ${host} is outside the instance under test (allowed: ${allowedHosts.join(", ")}). Stay on the app.`; +} + +function deny(reason: string) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse" as const, + permissionDecision: "deny" as const, + permissionDecisionReason: reason, + }, + }; +} + +/** + * After every browser action, checks the page for a CAPTCHA or bot check and + * tells the agent to hand over to a person instead of trying to solve it. + */ +export function createCaptchaHook(page: BrowserAttachment["activePage"], humanAvailable: boolean): HookCallbackMatcher { + const hook: HookCallback = async () => { + const captcha = await detectCaptcha(page()); + if (!captcha) return {}; + return { + hookSpecificOutput: { + hookEventName: "PostToolUse" as const, + additionalContext: humanAvailable + ? `A ${captcha} is on the page. Do not try to solve it. Call request_human_help and continue after the person is done.` + : `A ${captcha} is on the page. Do not try to solve it. Record the affected checks as blocked and continue with what you can test.`, + }, + }; + }; + return { matcher: "^mcp__playwright__browser_", hooks: [hook] }; +} + +/** + * Soft stop: once the signal fires, every further tool call is denied with an + * instruction to finish. The agent ends its turn and the SDK still reports the + * run's real cost, which a hard abort would lose. + */ +export function createStopHook(signal: AbortSignal | undefined): HookCallbackMatcher[] { + if (!signal) return []; + const hook: HookCallback = async () => { + if (!signal.aborted) return {}; + return { + hookSpecificOutput: { + hookEventName: "PreToolUse" as const, + permissionDecision: "deny" as const, + permissionDecisionReason: "The run was cancelled by a person. Do not call any more tools. Reply with one short sentence and end your turn now.", + }, + }; + }; + return [{ matcher: ".*", hooks: [hook] }]; +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts new file mode 100644 index 0000000..ea4f8e2 --- /dev/null +++ b/packages/agent/src/index.ts @@ -0,0 +1,8 @@ +export * from "./runner.js"; +export * from "./prompts.js"; +export * from "./hooks.js"; +export * from "./sdk-tools.js"; +export * from "./analysis-prompt.js"; +export * from "./analysis-runner.js"; +export * from "./judge.js"; +export * from "./test-runner.js"; diff --git a/packages/agent/src/judge.ts b/packages/agent/src/judge.ts new file mode 100644 index 0000000..125e97f --- /dev/null +++ b/packages/agent/src/judge.ts @@ -0,0 +1,106 @@ +import { query, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { modelEnv } from "./runner.js"; +import { JudgeVerdictSchema, describeStep, isAssertion, resolveStepTemplates, type JudgeVerdict, type Language, type StepResult, type TestScript } from "@claudio/core"; + +export type JudgeInput = { + script: Pick; + steps: StepResult[]; + /** Value of {{run}} in this run, so the judge sees the actual test data. */ + runTag: string; + finalUrl: string; + /** Accessibility tree of the final page. */ + finalSnapshot: string; + /** Absolute path of a screenshot of the final state. */ + screenshotPath: string | null; + errors: string[]; + /** What the agent that ran the test reported, if one ran. */ + runnerSummary?: string; + language: Language; + model: string; + runDir: string; + abortController?: AbortController; + /** The repository's Anthropic API key. */ + apiKey?: string; +}; + +export type JudgeResult = { verdict: JudgeVerdict | null; costUsd: number; error?: string }; + +const VERDICT_JSON_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["passed", "confidence", "deviations", "reasoning"], + properties: { + passed: { type: "boolean", description: "true if every part of the expectation holds in the final state" }, + confidence: { type: "number", minimum: 0, maximum: 1, description: "how sure you are, 0 to 1" }, + deviations: { type: "array", items: { type: "string" }, description: "each part of the expectation that does not hold, concretely" }, + reasoning: { type: "string", description: "two to four sentences on how you checked each part of the expectation" }, + }, +} as const; + +/** + * Independent judge: sees only the expectation and the evidence of the run, not + * the tester's reasoning, and answers with structured output. + */ +export async function runJudge(input: JudgeInput): Promise { + const lang = input.language === "de" ? "German" : "English"; + const show = (r: StepResult) => describeStep(resolveStepTemplates(r.step, { runTag: input.runTag })); + // Failed actions while recording are discarded attempts (wrong locator, retried); only failed checks carry information. + const steps = input.steps.filter((r) => r.ok).map((r, i) => `${i + 1}. ${show(r)}`).join("\n"); + const failedChecks = input.steps.filter((r) => !r.ok && isAssertion(r.step)).map((r) => `- ${show(r)}: ${r.error}`).join("\n"); + const prompt = `You judge whether an automated UI test met its expectation. Decide only from the evidence below${input.screenshotPath ? " and the screenshot (Read it)" : ""}. Do not assume anything that is not shown. + +# Test "${input.script.name}" + +What should be done: +${input.script.description} + +Expectation (what must be true at the end): +${input.script.expectation} + +# Steps that were executed successfully +${steps || "(none)"} + +# Checks that did not hold +${failedChecks || "none"} + +# Final state +URL: ${input.finalUrl} +${input.screenshotPath ? `Screenshot: ${input.screenshotPath}\n` : ""} +Accessibility tree: +${input.finalSnapshot} + +# Errors during the run +${input.errors.length ? input.errors.map((e) => `- ${e}`).join("\n") : "none"} +${input.runnerSummary ? `\n# Note from the tester (may be wrong)\n${input.runnerSummary}\n` : ""} +Rules: passed only if every part of the expectation is evidently satisfied in the final state and the steps. A failed check counts against the test when it concerns the expectation; console noise or checks unrelated to the expectation do not fail the test, but mention them in reasoning. An error page or HTTP 5xx in the flow means not passed. If the evidence is insufficient to decide, set passed false, confidence below 0.5 and say what is missing. Write deviations and reasoning in ${lang}.`; + + let result: SDKResultMessage | undefined; + try { + for await (const m of query({ + prompt, + options: { + model: input.model, + cwd: input.runDir, + settingSources: [], + systemPrompt: "You are a strict, fair QA judge. You answer only with the requested structured verdict.", + outputFormat: { type: "json_schema", schema: VERDICT_JSON_SCHEMA as unknown as Record }, + allowedTools: ["Read"], + disallowedTools: ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Agent", "Glob", "Grep"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: 6, + maxBudgetUsd: 1, + abortController: input.abortController, + env: modelEnv(process.env, input.apiKey), + }, + })) { + if (m.type === "result") result = m; + } + } catch (e) { + return { verdict: null, costUsd: 0, error: `judge failed: ${(e as Error).message}` }; + } + const costUsd = result?.total_cost_usd ?? 0; + if (!result || result.subtype !== "success") return { verdict: null, costUsd, error: `judge ended with ${result?.subtype ?? "no result"}` }; + const parsed = JudgeVerdictSchema.safeParse(result.structured_output); + return parsed.success ? { verdict: parsed.data, costUsd } : { verdict: null, costUsd, error: "judge returned an invalid verdict" }; +} diff --git a/packages/agent/src/prompts.ts b/packages/agent/src/prompts.ts new file mode 100644 index 0000000..41e38f4 --- /dev/null +++ b/packages/agent/src/prompts.ts @@ -0,0 +1,186 @@ +import { KNOWLEDGE_DOCS, type BudgetProfile, type KnowledgeBase, type RepoConfig, type ReviewRequest } from "@claudio/core"; +import type { PullRequestContext } from "@claudio/github"; + +export const SYSTEM_APPEND = ` +You are Claudio, an automated user-acceptance reviewer for web applications. You replace the manual review a QA engineer performs before a feature branch is merged: verify the ticket's acceptance criteria in the running app, check that existing behaviour did not regress, judge UX and UI consistency, and document everything with evidence. + +Working rules: +- Work adaptively. Do not assume specific test data exists. Discover the app's state from the UI, create what you need, and prefix names of records you create with "claudio-" so people can find and clean them up. Note every record you create with the note tool. +- Evidence first. Every finding and every acceptance-criterion result must reference evidence captured with capture_evidence. A claim without evidence is worth little to the humans reading the report. +- Record results through the Claudio tools (report_finding, record_ac_result), not only in prose. The report is generated from those records; the prose is the summary. +- Severity: blocker = feature unusable or data loss; major = acceptance criterion not met or a clear functional bug; minor = wrong but workable; suggestion = improvement idea. Do not inflate. +- Be precise about what you observed versus what you infer. If something could not be tested (missing data, missing permission, broken login), record it as blocked or not_testable rather than guessing. +- Never type credentials into the browser. Use login_as. +- CAPTCHAs and bot checks are for humans. Never try to solve one. If request_human_help is available, call it; otherwise record the blocked part and continue. +- Stay inside the instance under test. Do not interact with production systems or external services. +- Read the repository with Read/Grep/Glob to understand what the diff touches; you cannot run commands. +`.trim(); + +export type PromptInput = { + runId: string; + request: ReviewRequest; + pr: PullRequestContext; + config: RepoConfig | null; + repoDir: string | null; + profile: BudgetProfile; + driverPrefix: string; + maxDiffChars?: number; + /** Knowledge base files written for this run. */ + knowledge?: { dir: string; base: KnowledgeBase } | null; +}; + +export function buildReviewPrompt(input: PromptInput): string { + const { request, pr, config, repoDir, profile, driverPrefix } = input; + const roles = Object.entries(config?.roles ?? {}); + const lang = request.language === "de" ? "German" : "English"; + + const verbosityGuide = { + tldr: "Keep the final summary to a few sentences: verdict, what works, what blocks.", + normal: "Write a concise summary (one or two paragraphs) plus a short list of the most important observations.", + detailed: "Write a thorough summary: what you tested in which order, what you could not test and why, observations beyond the findings, and concrete improvement ideas.", + }[request.verbosity]; + + const strictnessGuide = { + lenient: "Report only real functional problems as blocker/major; treat polish and consistency issues as suggestions.", + normal: "Report functional problems and clear UX/UI inconsistencies; use minor for cosmetic issues and suggestion for ideas.", + strict: "Be thorough and exacting: inconsistent wording, spacing, missing feedback, missing loading or error states and accessibility problems are findings (minor), not suggestions.", + }[request.strictness]; + + const sections: string[] = []; + sections.push(driverPrefix); + sections.push(`# Review task\n\nRun id: ${input.runId}\nReport language: ${lang}\nVerbosity: ${request.verbosity}. ${verbosityGuide}\nStrictness: ${request.strictness}. ${strictnessGuide}\nBudget: about ${profile.maxTurns} turns and ${profile.maxBrowserSteps} browser steps. Plan accordingly: cover acceptance criteria first, then regressions on the affected areas, then UX/UI.`); + + sections.push(`# Pull request #${pr.number}: ${pr.title}\n\nURL: ${pr.url}\nBranch: ${pr.headRef} → ${pr.baseRef}${pr.author ? `\nAuthor: ${pr.author}` : ""}${pr.draft ? "\nDraft PR" : ""}\n\n## Description\n\n${pr.body.trim() || "(empty)"}`); + + if (pr.issues.length > 0) { + sections.push( + `# Linked issues\n\n` + + pr.issues + .map((i) => `## #${i.number}: ${i.title}${i.labels.length ? ` [${i.labels.join(", ")}]` : ""}\n${i.url}\n\n${i.body.trim() || "(empty)"}`) + .join("\n\n"), + ); + } else { + sections.push("# Linked issues\n\nNone found. Derive the acceptance criteria from the PR description and the diff, and say in the summary that no ticket was linked."); + } + + sections.push(renderDiff(pr, input.maxDiffChars ?? 60_000)); + + sections.push( + `# Instance under test\n\nBase URL: ${request.instanceUrl}\n` + + (roles.length > 0 + ? `Roles available via login_as: ${roles.map(([n, r]) => `${n}${r.description ? ` (${r.description})` : ""}`).join(", ")}.` + + (request.roles.length > 0 ? ` Start with: ${request.roles.join(", ")}.` : "") + : "No roles are configured. If the app needs a login, record the affected criteria as blocked."), + ); + + if (repoDir) { + sections.push(`# Repository checkout\n\nThe PR branch is checked out at ${repoDir}. Use Read/Grep/Glob there for impact analysis (routes, components, shared modules the diff touches). You may delegate the code reading to the impact-analyst subagent.`); + } + + if (input.knowledge) sections.push(renderKnowledgeSection(input.knowledge.dir, input.knowledge.base, "review")); + + if (config?.notes) sections.push(`# Notes about this application\n\n${config.notes.trim()}`); + if (config?.lessons?.length) { + sections.push( + `# Decisions from earlier reviews\n\nPeople dismissed these findings in earlier reviews of this repository. Treat the described behaviour as intended: do not report it again unless this pull request changes it.\n\n${config.lessons.map((l) => `- ${l}`).join("\n")}`, + ); + } + if (request.extraInstructions) sections.push(`# Additional instructions for this run\n\n${request.extraInstructions.trim()}`); + + sections.push(profile.delegateChecks ? delegatedProcedure(lang) : singleAgentProcedure(lang)); + + return sections.join("\n\n"); +} + +/** Routes and app map are inlined for reviews up to this size; larger knowledge bases stay files. */ +export const KNOWLEDGE_INLINE_LIMIT = 24_000; + +/** + * Knowledge base section. For reviews, routes.md and app-map.md are inlined when + * they are small, so the impact analysis does not depend on the agent choosing to + * read them; everything else is referenced by path. + */ +export function renderKnowledgeSection(dir: string, base: KnowledgeBase, purpose: "review" | "analysis"): string { + const header = `# Knowledge base (commit ${base.commitSha.slice(0, 7)} on ${base.branch}, analysed ${base.analyzedAt.slice(0, 10)})`; + const files = base.docs.map((d) => `- ${dir}/${KNOWLEDGE_DOCS[d.name].file}: ${KNOWLEDGE_DOCS[d.name].purpose}`).join("\n"); + if (purpose === "analysis") { + return `${header}\n\n${files}\n\nThis is the knowledge base from the previous analysis. Update it rather than starting over: keep what is still true, correct what changed, add what is new.`; + } + + const core = base.docs.filter((d) => d.name === "routes" || d.name === "app-map"); + const inline = core.length > 0 && core.reduce((n, d) => n + d.content.length, 0) <= KNOWLEDGE_INLINE_LIMIT; + const caveat = + "The code may have changed since this analysis; trust the running app and the diff over the knowledge base when they disagree, and note outdated entries with the note tool."; + if (inline) { + const body = core.map((d) => `## ${KNOWLEDGE_DOCS[d.name].file}\n\n${d.content.trim()}`).join("\n\n"); + return `${header}\n\nUse the route index below to map the changed files to screens, and the app map as the expected behaviour of those screens: a deviation from it is a regression candidate. ${caveat}\n\nFurther documents (read when useful):\n${files}\n\n${body}`; + } + return `${header}\n\n${files}\n\nBefore the impact analysis, Read routes.md to map the changed files to screens. Before testing a screen, Read its app-map.md section: it is the expected behaviour, so a deviation is a regression candidate. Read notes.md for test data and quirks. ${caveat}`; +} + +const SUMMARY_STEP = (lang: string) => `Finish with a summary in ${lang} as your final message. Structure it as: + - A verdict sentence. + - Summary (per the verbosity setting). + - A section titled "Affected areas:" with one bullet per affected screen or flow (one line each, keep this heading in English). + - Anything you could not test and why. +Do not repeat every finding in the summary; they are rendered from the records.`; + +/** Quick budget: one agent does everything. */ +function singleAgentProcedure(lang: string): string { + return `# Procedure + +1. Extract the acceptance criteria from the issue(s) and PR description. If they are implicit, formulate them explicitly. Add them to the todo list with todo_add, one item per criterion. +2. Impact analysis: from the diff (and the checkout, if available) determine which screens, routes, flows and shared components are affected. Add regression checks for those to the todo list. Keep this proportional to the size of the change. +3. Log in with login_as and build a mental map of the app around the affected areas (snapshot, navigate, read). Do not click through unrelated areas. +4. Work the todo list with todo_next / todo_done. For each criterion: perform the flow, capture_evidence, check get_console and get_network for errors, then record_ac_result. For each regression item: verify the existing behaviour still works and record findings when it does not. +5. Judge UX and UI of the changed screens: consistency with the rest of the app, feedback on actions, error states, wording, layout. You may delegate screenshot review to the ux-reviewer subagent. Record findings with report_finding. +6. ${SUMMARY_STEP(lang)}`; +} + +/** + * Standard and thorough budgets: separate subagents verify the criteria, check + * regressions and judge the UI, each with a fresh context. They share one + * browser, so they run one after the other. + */ +function delegatedProcedure(lang: string): string { + return `# Procedure + +You coordinate specialised subagents. The browser subagents (ac-verifier, regression-tester) share one browser: run them one after the other, never in parallel. They record their results with the Claudio tools themselves; do not record the same results again. + +1. Extract the acceptance criteria from the issue(s) and PR description. If they are implicit, formulate them explicitly as testable statements, numbered. +2. Impact analysis: map the changed files to screens and flows (route index from the knowledge base, if available). For diffs larger than a handful of files or touching shared modules, delegate this to the impact-analyst subagent. Result: affected areas, each with direct/indirect and one concrete regression check, ordered by risk. +3. Delegate to ac-verifier. Give it: run id, instance URL, roles, the numbered criteria, the app-map entries of the screens involved, and notes on test data. +4. Delegate to regression-tester. Give it: run id, instance URL, roles, the affected areas with their checks, and the app-map entries of those screens. Skip this step when the change is isolated and no area beyond the new feature is affected; say so in the summary. +5. Delegate to ux-reviewer: the evidence file paths of the changed screens (from the evidence directory of this run) with one line on each screen's purpose. Record the observations you agree with via report_finding (category ux or ui), with the evidence ids. +6. Check the records: every criterion has a result (record_ac_result for any the ac-verifier missed, as blocked with the reason). Do not re-test what the subagents covered. +7. ${SUMMARY_STEP(lang)}`; +} + +function renderDiff(pr: PullRequestContext, maxChars: number): string { + const lines: string[] = [`# Changed files (${pr.files.length})`, ""]; + for (const f of pr.files) lines.push(`- ${f.status} ${f.filename}${f.previousFilename ? ` (was ${f.previousFilename})` : ""} (+${f.additions} -${f.deletions})`); + lines.push("", "## Patches", ""); + let used = 0; + let omitted = 0; + for (const f of pr.files) { + if (!f.patch) continue; + const block = `### ${f.filename}\n\`\`\`diff\n${f.patch}\n\`\`\`\n`; + if (used + block.length > maxChars) { + omitted++; + continue; + } + used += block.length; + lines.push(block); + } + if (omitted > 0) lines.push(`(${omitted} patches omitted for size; read those files in the checkout if relevant.)`); + return lines.join("\n"); +} + +export function extractAffectedAreas(summary: string): string[] { + const m = summary.match(/affected areas:?\s*\n([\s\S]*?)(?:\n\s*\n|$)/i); + if (!m) return []; + return m[1]! + .split("\n") + .map((l) => l.replace(/^\s*[-*•]\s*/, "").trim()) + .filter((l) => l.length > 0 && !/^#/.test(l)); +} diff --git a/packages/agent/src/runner.ts b/packages/agent/src/runner.ts new file mode 100644 index 0000000..ce6e49c --- /dev/null +++ b/packages/agent/src/runner.ts @@ -0,0 +1,335 @@ +import { execFile } from "node:child_process"; +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { query, type Options, type SDKMessage, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { RunStore, createClaudioTools, createDriver, startLiveView, type DriverSession, type HumanBridgeFactory } from "@claudio/browser"; +import { + ReviewRequestSchema, + budgetProfile, + loadRepoConfig, + newRunId, + nowIso, + renderMarkdown, + renderPrComment, + KNOWLEDGE_DOCS, + type KnowledgeBase, + type RepoConfig, + type ReviewReport, + type ReviewRequest, + type RunStatus, +} from "@claudio/core"; +import { createGithubClient, fetchPullRequestContext, postPullRequestComment, type PullRequestContext } from "@claudio/github"; +import { createBrowserGuard, createCaptchaHook, createStopHook } from "./hooks.js"; +import { SYSTEM_APPEND, buildReviewPrompt, extractAffectedAreas } from "./prompts.js"; +import { createClaudioSdkServer } from "./sdk-tools.js"; + +const execFileAsync = promisify(execFile); + +export type RunnerDeps = { + /** Directory that holds one sub-directory per run. Default: ./runs */ + runsRoot?: string; + /** Path to claudio.config.json. Default: ./claudio.config.json */ + configPath?: string; + /** Repo config passed in directly (web worker); takes precedence over configPath. */ + config?: RepoConfig | null; + /** Role credentials keyed by the env var names in the config. Only the Claudio tools see them, never the agent process. */ + roleEnv?: Record; + /** Claudio project root (where .claude/agents and skills live). Default: process.cwd() */ + projectRoot?: string; + githubToken?: string; + /** Use a PullRequestContext JSON file instead of fetching from GitHub (offline runs, demo target). */ + prContextFile?: string; + /** Use an existing checkout instead of cloning the PR branch. */ + repoDir?: string; + abortController?: AbortController; + /** The repository's Anthropic API key. When set, the run uses only this key. */ + anthropicApiKey?: string; + /** Soft stop: the agent is told to finish, so the SDK still reports the cost. abortController is the hard fallback. */ + stopSignal?: AbortSignal; + onProgress?: (line: string) => void; + /** Latest knowledge base of the repository (from a repo analysis), handed to the agent as files. */ + knowledge?: KnowledgeBase | null; + /** Lets a person take over the browser for CAPTCHAs and bot checks (web UI). */ + human?: HumanBridgeFactory; + /** Write the browser screencast to runDir/live.jpg for the web UI. Default: true. */ + liveView?: boolean; + /** Link to this review in the web UI, used in the PR comment. */ + reportUrl?: string; + /** Called once the run directory exists, before any network or model call. */ + onRunStarted?: (run: RunHandle) => void; +}; + +export type RunHandle = { runId: string; runDir: string }; + +export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Promise { + const request = ReviewRequestSchema.parse(input); + const log = deps.onProgress ?? (() => undefined); + const runId = newRunId(`review-pr${request.pr}`); + const runsRoot = resolve(deps.runsRoot ?? "runs"); + const runDir = join(runsRoot, runId); + const store = new RunStore(runDir); + const started = Date.now(); + const errors: string[] = []; + log(`run ${runId} → ${runDir}`); + deps.onRunStarted?.({ runId, runDir }); + + const config = deps.config !== undefined ? deps.config : loadRepoConfig(deps.configPath); + if (config && config.repo !== request.repo) log(`warning: claudio.config.json is for ${config.repo}, reviewing ${request.repo}`); + const roleEnvVars = Object.values(config?.roles ?? {}).flatMap((r) => [r.usernameEnv, r.passwordEnv]); + + const token = deps.githubToken ?? process.env.GITHUB_TOKEN; + let pr: PullRequestContext; + if (deps.prContextFile) { + pr = JSON.parse(readFileSync(deps.prContextFile, "utf8")) as PullRequestContext; + log(`using PR context from ${deps.prContextFile}`); + } else { + log(`fetching PR #${request.pr} of ${request.repo}`); + pr = await fetchPullRequestContext(createGithubClient(token), request.repo, request.pr); + } + store.writeFile("pr-context.json", pr); + log(`PR "${pr.title}" (${pr.headRef} → ${pr.baseRef}), ${pr.files.length} files, ${pr.issues.length} linked issues`); + + let repoDir: string | null = null; + if (deps.repoDir) { + repoDir = resolve(deps.repoDir); + } else if (request.cloneRepo && token) { + repoDir = join(runDir, "repo"); + try { + await cloneBranch(request.repo, pr.headRef, repoDir, token); + log(`checked out ${pr.headRef} at ${repoDir}`); + } catch (e) { + repoDir = null; + const msg = `clone failed: ${scrub((e as Error).message, token)}`; + errors.push(msg); + log(msg); + } + } + + const profile = budgetProfile(request.budget, request.modelOverride); + const driver = createDriver(request.driver); + const session: DriverSession = await driver.start({ + runDir, + instanceUrl: request.instanceUrl, + headed: request.headed, + viewport: { width: 1280, height: 800 }, + language: request.language, + }); + log(`browser driver: ${session.name}${session.attachment ? ` (cdp ${session.attachment.cdpEndpoint})` : ""}`); + const human = session.attachment && deps.human ? deps.human(session.attachment) : undefined; + // Screencast for the live view in the web UI (runDir/live.jpg). + const liveView = session.attachment && deps.liveView !== false ? startLiveView(session.attachment, join(runDir, "live.jpg")) : null; + + const tools = createClaudioTools({ + runDir, + store, + attachment: session.attachment, + instanceUrl: request.instanceUrl, + roles: config?.roles ?? {}, + driver: session.name, + env: deps.roleEnv ? { ...process.env, ...deps.roleEnv } : undefined, + human, + }); + const claudioServer = createClaudioSdkServer(tools); + + const instanceHost = new URL(request.instanceUrl).hostname; + const guard = createBrowserGuard({ + allowedHosts: [instanceHost, ...(config?.extraAllowedHosts ?? [])], + maxBrowserSteps: profile.maxBrowserSteps, + onStep: (n) => { + if (n % 25 === 0) log(`browser steps: ${n}/${profile.maxBrowserSteps}`); + }, + }); + + const knowledgeDir = deps.knowledge?.docs.length ? writeKnowledgeFiles(join(runDir, "knowledge"), deps.knowledge) : null; + if (knowledgeDir && deps.knowledge) log(`knowledge base from ${deps.knowledge.commitSha.slice(0, 7)} (${deps.knowledge.docs.length} documents)`); + + const prompt = buildReviewPrompt({ + runId, + request, + pr, + config, + repoDir, + profile, + driverPrefix: session.promptPrefix, + knowledge: knowledgeDir && deps.knowledge ? { dir: knowledgeDir, base: deps.knowledge } : null, + }); + store.writeFile("prompt.md", prompt); + + const abortController = deps.abortController ?? new AbortController(); + const options: Options = { + cwd: resolve(deps.projectRoot ?? process.cwd()), + settingSources: ["project"], + systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_APPEND }, + model: profile.orchestratorModel, + effort: profile.effort, + thinking: { type: "adaptive" }, + maxTurns: profile.maxTurns, + maxBudgetUsd: profile.maxBudgetUsd, + mcpServers: { ...session.mcpServers, claudio: claudioServer }, + // Only the servers above: the repo's .mcp.json (live-mode server, same name) must not be loaded here. + strictMcpConfig: true, + allowedTools: [...session.allowedTools, "Read", "Grep", "Glob", "Agent", "TodoWrite"], + disallowedTools: [...session.disallowedTools, "Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "KillShell", "BashOutput"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + hooks: { + PreToolUse: [...createStopHook(deps.stopSignal), ...guard.hooks], + PostToolUse: session.attachment ? [createCaptchaHook(() => session.attachment?.activePage(), Boolean(human))] : [], + }, + additionalDirectories: [repoDir, knowledgeDir].filter((d): d is string => Boolean(d)), + extraArgs: session.extraArgs, + abortController, + env: modelEnv(envWithout(process.env, roleEnvVars), deps.anthropicApiKey), + stderr: (data) => appendFileSync(join(runDir, "claude-stderr.log"), data), + }; + + const logFile = join(runDir, "log.jsonl"); + let result: SDKResultMessage | undefined; + let sessionId: string | undefined; + let status: RunStatus = "failed"; + try { + for await (const message of query({ prompt, options })) { + appendFileSync(logFile, JSON.stringify(message) + "\n"); + sessionId ??= (message as { session_id?: string }).session_id; + describe(message, log); + if (message.type === "result") result = message; + } + status = deps.stopSignal?.aborted ? "aborted" : mapStatus(result); + if (result && result.subtype !== "success") errors.push(...result.errors); + } catch (e) { + status = abortController.signal.aborted ? "aborted" : "failed"; + errors.push(`agent error: ${(e as Error).message}`); + log(`agent error: ${(e as Error).message}`); + } finally { + await liveView?.stop().catch(() => undefined); + await session.close().catch((e) => errors.push(`driver close: ${(e as Error).message}`)); + } + + const summary = result && result.subtype === "success" ? result.result : ""; + const report: ReviewReport = { + runId, + status, + request, + pr: { number: pr.number, title: pr.title, url: pr.url, headRef: pr.headRef, baseRef: pr.baseRef, author: pr.author, headSha: pr.headSha }, + issues: pr.issues.map((i) => ({ number: i.number, title: i.title, url: i.url })), + summary, + affectedAreas: extractAffectedAreas(summary), + acceptanceCriteria: store.acResults(), + findings: store.findings(), + evidence: store.evidence(), + notes: store.notes(), + cost: { + usd: result?.total_cost_usd ?? 0, + inputTokens: (result?.usage.input_tokens ?? 0) + (result?.usage.cache_read_input_tokens ?? 0) + (result?.usage.cache_creation_input_tokens ?? 0), + outputTokens: result?.usage.output_tokens ?? 0, + turns: result?.num_turns ?? 0, + durationMs: Date.now() - started, + browserSteps: guard.steps(), + }, + sessionId, + errors, + createdAt: nowIso(), + }; + + writeFileSync(join(runDir, "report.json"), JSON.stringify(report, null, 2)); + writeFileSync(join(runDir, "report.md"), renderMarkdown(report, request.verbosity)); + writeFileSync(join(runDir, "report-detailed.md"), renderMarkdown(report, "detailed")); + log(`report: ${join(runDir, "report.md")} (status ${status}, ${report.cost.usd.toFixed(2)} USD, ${report.findings.length} findings)`); + + if (request.postComment) { + try { + const url = await postPullRequestComment(createGithubClient(token), request.repo, request.pr, renderPrComment(report, deps.reportUrl)); + report.commentUrl = url; + writeFileSync(join(runDir, "report.json"), JSON.stringify(report, null, 2)); + log(`PR comment posted: ${url}`); + } catch (e) { + const msg = `PR comment failed: ${(e as Error).message}`; + report.errors.push(msg); + log(msg); + writeFileSync(join(runDir, "report.json"), JSON.stringify(report, null, 2)); + } + } + return report; +} + +/** Writes the knowledge documents as Markdown files and returns the directory. */ +export function writeKnowledgeFiles(dir: string, knowledge: KnowledgeBase): string { + mkdirSync(dir, { recursive: true }); + for (const doc of knowledge.docs) writeFileSync(join(dir, KNOWLEDGE_DOCS[doc.name].file), doc.content); + return dir; +} + +export function mapStatus(result: SDKResultMessage | undefined): RunStatus { + if (!result) return "failed"; + switch (result.subtype) { + case "success": + return "completed"; + case "error_max_budget_usd": + return "budget_exceeded"; + case "error_max_turns": + return "max_turns"; + default: + return "failed"; + } +} + +export function describe(message: SDKMessage, log: (line: string) => void): void { + if (message.type === "system" && message.subtype === "init") { + log(`session ${message.session_id} model=${message.model} key=${message.apiKeySource} mcp=${message.mcp_servers.map((s) => `${s.name}:${s.status}`).join(",")}`); + return; + } + if (message.type === "assistant") { + for (const block of message.message.content) { + if (block.type === "tool_use") { + log(`→ ${block.name} ${short(JSON.stringify(block.input), 100)}`); + } else if (block.type === "text" && block.text.trim()) { + log(`· ${short(block.text.trim().replace(/\s+/g, " "), 160)}`); + } + } + return; + } + if (message.type === "result") { + log(`result: ${message.subtype}, ${message.num_turns} turns, ${message.total_cost_usd.toFixed(2)} USD, ${Math.round(message.duration_ms / 1000)}s`); + } +} + +export function short(text: string, max: number): string { + return text.length <= max ? text : text.slice(0, max - 1) + "…"; +} + +/** Shallow clone of one branch. Returns the checked-out commit sha. */ +export async function cloneBranch(repo: string, branch: string, dir: string, token: string): Promise { + const url = `https://x-access-token:${token}@github.com/${repo}.git`; + const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; + await execFileAsync("git", ["clone", "--depth", "1", "--branch", branch, "--single-branch", url, dir], { env, maxBuffer: 10 * 1024 * 1024 }); + // The token is part of the remote URL; drop it so it does not linger in .git/config. + await execFileAsync("git", ["-C", dir, "remote", "set-url", "origin", `https://github.com/${repo}.git`], { env }); + const { stdout } = await execFileAsync("git", ["-C", dir, "rev-parse", "HEAD"], { env }); + return stdout.trim(); +} + +export function scrub(text: string, secret?: string): string { + return secret ? text.split(secret).join("***") : text; +} + +/** Anthropic credentials a model session could pick up from the environment. */ +const ANTHROPIC_CREDENTIALS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"]; + +/** + * Environment for a model session. With a repository key, every other Anthropic + * credential is removed so the run cannot fall back to the server's key or login. + * Without one (CLI), the environment is used as it is. + */ +export function modelEnv(env: Record, apiKey: string | undefined): Record { + if (!apiKey) return env; + const out = envWithout(env as NodeJS.ProcessEnv, ANTHROPIC_CREDENTIALS); + out.ANTHROPIC_API_KEY = apiKey; + return out; +} + +export function envWithout(env: NodeJS.ProcessEnv, keys: string[]): Record { + const out: Record = { ...env }; + for (const k of keys) delete out[k]; + return out; +} diff --git a/packages/agent/src/sdk-tools.ts b/packages/agent/src/sdk-tools.ts new file mode 100644 index 0000000..49680e5 --- /dev/null +++ b/packages/agent/src/sdk-tools.ts @@ -0,0 +1,22 @@ +import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk"; +import type { ClaudioTool } from "@claudio/browser"; + +/** Wraps the neutral Claudio tool definitions as an in-process MCP server for the Agent SDK. */ +export function createClaudioSdkServer(tools: ClaudioTool[]): McpSdkServerConfigWithInstance { + return createSdkMcpServer({ + name: "claudio", + version: "0.1.0", + alwaysLoad: true, + instructions: + "Claudio tools record the review: login_as for authentication, capture_evidence for screenshots, report_finding and record_ac_result for results, todo_* and note for working memory, get_console / get_network for runtime errors.", + tools: tools.map((t) => + tool( + t.name, + t.description, + t.schema, + async (args) => ({ content: [{ type: "text" as const, text: await t.handler(args as never) }] }), + { annotations: { readOnlyHint: t.readOnly ?? false }, alwaysLoad: true }, + ), + ), + }); +} diff --git a/packages/agent/src/test-runner.ts b/packages/agent/src/test-runner.ts new file mode 100644 index 0000000..8108283 --- /dev/null +++ b/packages/agent/src/test-runner.ts @@ -0,0 +1,474 @@ +import { appendFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { query, type HookCallback, type Options, type PreToolUseHookInput, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { + RunStore, + StepRecorder, + ariaSnapshot, + createClaudioTools, + detectCaptcha, + guardNavigation, + createDriver, + createTestTools, + executeStep, + startLiveView, + testPage, + type HumanBridge, + type HumanBridgeFactory, + type StepContext, +} from "@claudio/browser"; +import { + TestRunRequestSchema, + describeStep, + newRunId, + nowIso, + testProfile, + type BudgetProfile, + type JudgeVerdict, + type KnowledgeBase, + type RepoConfig, + type StepResult, + type TestMode, + type TestOutcome, + type TestRunReport, + type TestRunRequest, + type TestScript, + type TestStep, +} from "@claudio/core"; +import { runJudge } from "./judge.js"; +import { renderKnowledgeSection } from "./prompts.js"; +import { createStopHook } from "./hooks.js"; +import { describe, envWithout, mapStatus, modelEnv, writeKnowledgeFiles, type RunHandle } from "./runner.js"; +import { createClaudioSdkServer } from "./sdk-tools.js"; + +export type TestRunnerDeps = { + runsRoot?: string; + projectRoot?: string; + config: RepoConfig | null; + roleEnv?: Record; + knowledge?: KnowledgeBase | null; + abortController?: AbortController; + /** The repository's Anthropic API key (see RunnerDeps.anthropicApiKey). */ + anthropicApiKey?: string; + /** Without a key, do not start any model session (replays still run). Set by the worker. */ + requireApiKey?: boolean; + /** Soft stop (see RunnerDeps.stopSignal). */ + stopSignal?: AbortSignal; + onProgress?: (line: string) => void; + onRunStarted?: (run: RunHandle) => void; + liveView?: boolean; + /** Lets a person take over the browser for CAPTCHAs and bot checks. */ + human?: HumanBridgeFactory; +}; + +const TEST_SYSTEM_APPEND = ` +You are Claudio, running an automated UI test of a web application. You perform the test exactly as described, through the test_step tool only: Claudio executes every step itself and records the successful ones as a replayable script. Use snapshot to see the page's roles and accessible names and pick targets from it. + +Rules for good recordings: +- Prefer {role, name} targets exactly as they appear in the snapshot; fall back to label, placeholder, text, testId, and css only when nothing else identifies the element uniquely. +- Start with login (if the flow needs a role) and goto, so a replay starts from a clean state. +- Put the expectation into assertion steps (expect_text, expect_visible, expect_url, expect_no_errors) wherever it can be checked mechanically. These assertions are what a replay checks without a model. +- Assertions must hold on every future run. Never assert values that change between runs: generated ids ("Fahrt #10"), timestamps, counters, totals. Assert the stable part ("angelegt") or the values you entered yourself (with {{run}}), e.g. expect_text "claudio-{{run}}" in the list. +- Locate rows and cells by the text you entered, not by their full content: {text: "claudio-{{run}}"} rather than a row name that includes ids or dates. +- Use {{run}} in names of records you create so repeated runs do not collide, and {{date:+N:FORMAT}} for dates (FORMAT as the app expects, e.g. DD.MM.YYYY or YYYY-MM-DD). +- Do not explore beyond the flow. Never type credentials; use login. +- CAPTCHAs and bot checks are for humans: never try to solve one. Call request_human_help; the person solves it in the live view and you continue. +- When the flow is done, call finish_test, then end with a short summary. +`.trim(); + +const BROWSER_GONE = /has been closed|Target closed|browser has disconnected|Browser closed/i; + +/** Short tag for {{run}}: time of day plus the run id's random suffix. */ +function runTagOf(runId: string): string { + return runId.split("-").slice(-2).join(""); +} + +export async function runTest(script: TestScript, input: TestRunRequest, deps: TestRunnerDeps): Promise { + const request = TestRunRequestSchema.parse(input); + const log = deps.onProgress ?? (() => undefined); + const runId = newRunId(`test-${script.id}`); + const runDir = join(resolve(deps.runsRoot ?? "runs"), runId); + const store = new RunStore(runDir); + const started = Date.now(); + const errors: string[] = []; + const profile = testProfile(request.budget); + const abortController = deps.abortController ?? new AbortController(); + log(`run ${runId} → ${runDir}`); + deps.onRunStarted?.({ runId, runDir }); + + const mode: TestMode = request.rerecord || !script.steps?.length ? "record" : script.determinism === "explorative" ? "explore" : "replay"; + log(`mode: ${mode === "record" ? "record (agent performs and records the flow)" : mode === "explore" ? "explore (agent runs the flow freely)" : `replay of ${script.steps!.length} steps without a model`}`); + + const session = await createDriver("playwright").start({ + runDir, + instanceUrl: request.instanceUrl, + headed: request.headed, + viewport: { width: 1280, height: 800 }, + language: request.language, + }); + const attachment = session.attachment; + if (!attachment) throw new Error("The Playwright driver did not provide a browser attachment."); + const liveView = deps.liveView !== false ? startLiveView(attachment, join(runDir, "live.jpg")) : null; + const human = deps.human ? deps.human(attachment) : undefined; + + const ctx: StepContext = { + attachment, + instanceUrl: request.instanceUrl, + roles: deps.config?.roles ?? {}, + env: { ...process.env, ...(deps.roleEnv ?? {}) }, + runTag: runTagOf(runId), + extraAllowedHosts: [...(deps.config?.extraAllowedHosts ?? []), ...(script.allowedHosts ?? [])], + allowAnyHost: script.allowAnyHost ?? false, + store, + baselineSeq: attachment.capture.lastSeq, + }; + await guardNavigation(ctx, (url) => log(`blocked navigation to ${url} (host not allowed for this test)`)); + await testPage(attachment); + + let steps: StepResult[] = []; + let compiledSteps: TestStep[] | undefined; + let healedAt: number | undefined; + let determinismProposal: TestRunReport["determinismProposal"]; + let runnerSummary: string | undefined; + let agentCost = 0; + let agentTurns = 0; + let status: TestRunReport["status"] = "completed"; + let assertionFailure: StepResult | undefined; + + const NO_KEY = "Für dieses Repository ist kein Anthropic-API-Key hinterlegt. Ein Admin kann ihn in den Repo-Einstellungen eintragen."; + const modelAllowed = !deps.requireApiKey || Boolean(deps.anthropicApiKey); + + const agentRun = async (prompt: string, recorder: StepRecorder, healing: boolean) => { + if (!modelAllowed) { + status = "failed"; + errors.push(NO_KEY); + log(NO_KEY); + return; + } + const res = await runTestAgent({ prompt, recorder, healing, ctx, store, runDir, profile, deps, abortController, log, language: request.language, human }); + agentCost += res.costUsd; + agentTurns += res.turns; + if (res.status !== "completed") status = res.status; + errors.push(...res.errors); + }; + + try { + if (mode === "replay") { + const planned = script.steps!; + for (let i = 0; i < planned.length; i++) { + if (abortController.signal.aborted || deps.stopSignal?.aborted) { + status = "aborted"; + break; + } + const step = planned[i]!; + // A CAPTCHA in the way: hand over to a person, then go on. Retried once if it broke the step. + if (!(await clearChallenge(attachment, human, log))) { + status = "failed"; + errors.push("Ein CAPTCHA oder eine Bot-Prüfung hat den Lauf blockiert, und niemand hat es gelöst."); + break; + } + let out = await executeStep(step, ctx); + if (!out.ok && (await detectCaptcha(attachment.activePage())) && (await clearChallenge(attachment, human, log))) { + out = await executeStep(step, ctx); + } + const result: StepResult = { index: i, step, ok: out.ok, error: out.error, durationMs: out.durationMs, healed: false }; + steps.push(result); + log(`${out.ok ? "✓" : "✗"} ${i + 1}/${planned.length} ${describeStep(step)}${out.error ? `: ${out.error}` : ""}`); + if (out.ok) continue; + if (out.assertionFailed) { + // Semi-deterministic: the judge decides, a single brittle check must not end the run. + if (script.determinism === "semi") continue; + assertionFailure = result; + break; + } + // A step that cannot be executed (element gone, renamed, moved): let the agent heal from here. + healedAt = i; + log(`step ${i + 1} broke, the agent takes over from here`); + const recorder = new StepRecorder(planned.slice(0, i)); + await agentRun(healPrompt(script, planned, i, out.error ?? "", request, deps), recorder, true); + steps = [...steps.slice(0, i), ...recorder.results]; + runnerSummary = recorder.finish?.summary; + if (recorder.finish?.complete) compiledSteps = recorder.compiled(); + store.addFinding({ + category: "regression", + severity: "minor", + title: `Testablauf musste angepasst werden: ${describeStep(step)}`, + description: `Schritt ${i + 1} ließ sich nicht mehr ausführen (${out.error}). Claudio hat den Ablauf ab dort neu aufgezeichnet. Prüfe, ob die Änderung an der Oberfläche beabsichtigt war.`, + steps: planned.slice(0, i + 1).map(describeStep), + expected: describeStep(step), + actual: out.error, + evidenceIds: [], + }); + break; + } + } else { + const recorder = new StepRecorder(); + await agentRun(mode === "record" ? recordPrompt(script, request, deps) : explorePrompt(script, request, deps), recorder, false); + steps = recorder.results; + runnerSummary = recorder.finish?.summary; + determinismProposal = mode === "record" ? recorder.finish?.determinism : undefined; + if (mode === "record" && recorder.finish?.complete && recorder.recorded.length > 0) compiledSteps = recorder.compiled(); + } + } catch (e) { + status = abortController.signal.aborted ? "aborted" : "failed"; + errors.push(`test error: ${(e as Error).message}`); + log(`test error: ${(e as Error).message}`); + } + + // A crashed browser makes every later step fail; report that instead of a test result. + const browserGone = !attachment.browser.isConnected() || steps.some((r) => r.error && BROWSER_GONE.test(r.error)); + if (browserGone && status === "completed") { + status = "failed"; + errors.push("Der Browser wurde während des Laufs unerwartet beendet. Starte den Test erneut."); + log("browser crashed during the run"); + } + + // Final state as evidence for people and for the judge. + let finalUrl = ""; + let finalSnapshot = ""; + let screenshotPath: string | null = null; + try { + const page = await testPage(attachment); + finalUrl = page.url(); + finalSnapshot = await ariaSnapshot(attachment, 12_000); + const id = store.nextEvidenceId(); + screenshotPath = join(store.evidenceDir, `${id}-Endzustand.png`); + await page.screenshot({ path: screenshotPath }); + store.addEvidence({ id, kind: "screenshot", label: "Endzustand", path: `evidence/${id}-Endzustand.png`, pageUrl: finalUrl }); + } catch { + // browser already gone (abort) + } + const consoleNetwork = [ + ...attachment.capture.queryConsole({ level: "error", sinceSeq: ctx.baselineSeq, limit: 10 }).map((e) => `console: ${e.text.slice(0, 200)}`), + ...attachment.capture.queryNetwork({ onlyErrors: true, onlyApi: true, sinceSeq: ctx.baselineSeq, limit: 10 }).map((e) => `${e.method} ${e.url} → ${e.failed ?? e.status}`), + ]; + + await liveView?.stop().catch(() => undefined); + await session.close().catch((e) => errors.push(`driver close: ${(e as Error).message}`)); + + // Outcome. A deterministic replay without failures needs no model at all. + let outcome: TestOutcome = "inconclusive"; + let judge: JudgeVerdict | undefined; + const allOk = steps.length > 0 && steps.every((s) => s.ok); + const needsJudge = status === "completed" && !assertionFailure && (mode !== "replay" || healedAt !== undefined || script.determinism === "semi"); + if (status !== "completed") { + outcome = "inconclusive"; + } else if (assertionFailure) { + outcome = "failed"; + } else if (!needsJudge) { + outcome = allOk ? "passed" : "failed"; + } else if (!modelAllowed) { + outcome = "inconclusive"; + errors.push(NO_KEY); + } else { + log("judge: comparing the final state with the expectation"); + const j = await runJudge({ + script, + steps, + runTag: ctx.runTag, + finalUrl, + finalSnapshot, + screenshotPath, + errors: consoleNetwork, + runnerSummary, + language: request.language, + model: profile.orchestratorModel, + runDir, + abortController, + apiKey: deps.anthropicApiKey, + }); + agentCost += j.costUsd; + if (j.error) errors.push(j.error); + judge = j.verdict ?? undefined; + outcome = !judge ? "inconclusive" : judge.passed ? (judge.confidence >= 0.5 ? "passed" : "inconclusive") : judge.confidence >= 0.5 ? "failed" : "inconclusive"; + if (judge) log(`judge: ${judge.passed ? "passed" : "not passed"} (confidence ${judge.confidence.toFixed(2)})`); + } + + const summary = + runnerSummary ?? + (assertionFailure + ? `Prüfung in Schritt ${assertionFailure.index + 1} fehlgeschlagen: ${assertionFailure.error}` + : outcome === "passed" + ? `Alle ${steps.length} Schritte und Prüfungen bestanden.` + : ""); + + const report: TestRunReport = { + runId, + status, + mode, + outcome, + healedAt, + steps, + compiledSteps, + determinismProposal, + judge, + summary, + findings: store.findings(), + evidence: store.evidence(), + finalUrl, + cost: { + usd: agentCost, + inputTokens: 0, + outputTokens: 0, + turns: agentTurns, + durationMs: Date.now() - started, + browserSteps: steps.length, + }, + errors, + createdAt: nowIso(), + }; + writeFileSync(join(runDir, "test-run.json"), JSON.stringify(report, null, 2)); + log(`test: ${outcome} (${mode}${healedAt !== undefined ? ", healed" : ""}), ${agentCost.toFixed(2)} USD`); + return report; +} + +/** Waits for a person when a CAPTCHA is on the page. False when it stays unsolved. */ +async function clearChallenge(attachment: StepContext["attachment"], human: HumanBridge | undefined, log: (line: string) => void): Promise { + const captcha = await detectCaptcha(attachment.activePage()); + if (!captcha) return true; + if (!human) { + log(`${captcha} on the page, and no person can take over in this run`); + return false; + } + const outcome = await human.wait(`${captcha} lösen`); + return outcome === "resolved"; +} + +// ---------------------------------------------------------------- agent session + +async function runTestAgent(a: { + prompt: string; + recorder: StepRecorder; + healing: boolean; + ctx: StepContext; + store: RunStore; + runDir: string; + profile: BudgetProfile; + deps: TestRunnerDeps; + abortController: AbortController; + log: (line: string) => void; + language: "de" | "en"; + human?: HumanBridge; +}): Promise<{ status: TestRunReport["status"]; costUsd: number; turns: number; errors: string[] }> { + const base = createClaudioTools({ + runDir: a.runDir, + store: a.store, + attachment: a.ctx.attachment, + instanceUrl: a.ctx.instanceUrl, + roles: a.ctx.roles, + driver: "playwright", + env: a.ctx.env, + human: a.human, + }).filter((t) => ["capture_evidence", "get_console", "get_network", "note", "report_finding", "request_human_help"].includes(t.name)); + const tools = [...createTestTools(a.ctx, a.recorder, { healing: a.healing }), ...base]; + + let knowledgeDir: string | null = null; + if (a.deps.knowledge?.docs.length) knowledgeDir = writeKnowledgeFiles(join(a.runDir, "knowledge"), a.deps.knowledge); + const roleEnvVars = Object.values(a.deps.config?.roles ?? {}).flatMap((r) => [r.usernameEnv, r.passwordEnv]); + + // The agent may read its own run directory (knowledge base), nothing else: the cwd is Claudio's + // repository, not the app under test. + const runRoot = resolve(a.runDir); + const readGuard: HookCallback = async (input) => { + const i = input as PreToolUseHookInput; + const path = String((i.tool_input as { file_path?: string })?.file_path ?? ""); + if (i.tool_name === "Read" && resolve(path).startsWith(runRoot + "/")) return {}; + return { + hookSpecificOutput: { + hookEventName: "PreToolUse" as const, + permissionDecision: "deny" as const, + permissionDecisionReason: "In test runs only files of this run (the knowledge base) can be read. Use snapshot to inspect the app.", + }, + }; + }; + + const options: Options = { + cwd: resolve(a.deps.projectRoot ?? process.cwd()), + settingSources: ["project"], + systemPrompt: { type: "preset", preset: "claude_code", append: TEST_SYSTEM_APPEND }, + hooks: { PreToolUse: [...createStopHook(a.deps.stopSignal), { matcher: "^(Read|Grep|Glob)$", hooks: [readGuard] }] }, + model: a.profile.orchestratorModel, + effort: a.profile.effort, + thinking: { type: "adaptive" }, + maxTurns: a.profile.maxTurns, + maxBudgetUsd: a.profile.maxBudgetUsd, + mcpServers: { claudio: createClaudioSdkServer(tools) }, + strictMcpConfig: true, + allowedTools: ["mcp__claudio__*", "Read"], + disallowedTools: ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "KillShell", "BashOutput", "Agent", "Grep", "Glob"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + additionalDirectories: knowledgeDir ? [knowledgeDir] : [], + abortController: a.abortController, + env: modelEnv(envWithout(process.env, roleEnvVars), a.deps.anthropicApiKey), + stderr: (data) => appendFileSync(join(a.runDir, "claude-stderr.log"), data), + }; + const fullPrompt = knowledgeDir && a.deps.knowledge ? `${a.prompt}\n\n${renderKnowledgeSection(knowledgeDir, a.deps.knowledge, "review")}` : a.prompt; + a.store.writeFile(a.healing ? "prompt-heal.md" : "prompt.md", fullPrompt); + + let result: SDKResultMessage | undefined; + const errors: string[] = []; + let status: TestRunReport["status"] = "failed"; + try { + for await (const m of query({ prompt: fullPrompt, options })) { + appendFileSync(join(a.runDir, "log.jsonl"), JSON.stringify(m) + "\n"); + describe(m, a.log); + if (m.type === "result") result = m; + } + status = a.deps.stopSignal?.aborted ? "aborted" : mapStatus(result); + if (result && result.subtype !== "success") errors.push(...result.errors); + } catch (e) { + status = a.abortController.signal.aborted ? "aborted" : "failed"; + errors.push(`agent error: ${(e as Error).message}`); + } + return { status, costUsd: result?.total_cost_usd ?? 0, turns: result?.num_turns ?? 0, errors }; +} + +// ---------------------------------------------------------------- prompts + +function scriptBlock(script: TestScript, request: TestRunRequest, deps: TestRunnerDeps): string { + const roles = Object.entries(deps.config?.roles ?? {}); + return `# Test "${script.name}" + +## What to do +${script.description.trim()} + +## Expectation (what must be true at the end) +${script.expectation.trim()} + +## Instance +Base URL: ${request.instanceUrl} +Hosts the test may open: ${script.allowAnyHost ? "any" : [new URL(request.instanceUrl).hostname, ...(deps.config?.extraAllowedHosts ?? []), ...(script.allowedHosts ?? [])].join(", ")}. For other hosts use goto with the full URL. +Roles for login: ${roles.length ? roles.map(([n, r]) => `${n}${r.description ? ` (${r.description})` : ""}`).join(", ") : "none configured"}${script.roles.length ? `. Use: ${script.roles.join(", ")}` : ""}. +${deps.config?.notes ? `\n## Notes about the application\n${deps.config.notes.trim()}\n` : ""} +Write summaries in ${request.language === "de" ? "German" : "English"}.`; +} + +function recordPrompt(script: TestScript, request: TestRunRequest, deps: TestRunnerDeps): string { + return `${scriptBlock(script, request, deps)} + +# Your task: record this test +Perform the flow with test_step, step by step, starting from login/goto. After each action take a snapshot when you need to see the result. Express every checkable part of the expectation as an assertion step. If the app does not behave as expected, still finish the flow as far as possible, report_finding for the bug with evidence, and call finish_test with outcome failed. Then call finish_test with your determinism proposal.`; +} + +function explorePrompt(script: TestScript, request: TestRunRequest, deps: TestRunnerDeps): string { + const hint = script.steps?.length ? `\n\nA previous run did it like this (adapt as needed):\n${script.steps.map((s, i) => `${i + 1}. ${describeStep(s)}`).join("\n")}` : ""; + return `${scriptBlock(script, request, deps)} + +# Your task: run this test +This test is explorative: the flow may need different choices each time. Perform it with test_step, check the expectation with assertion steps where possible, report_finding for bugs, and call finish_test.${hint}`; +} + +function healPrompt(script: TestScript, planned: TestStep[], brokenAt: number, error: string, request: TestRunRequest, deps: TestRunnerDeps): string { + const list = planned.map((s, i) => `${i + 1}. ${i < brokenAt ? "(done) " : i === brokenAt ? "(BROKE) " : ""}${describeStep(s)}`).join("\n"); + return `${scriptBlock(script, request, deps)} + +# Your task: repair a broken replay +This test was recorded earlier. Replaying it, steps 1 to ${brokenAt} worked, step ${brokenAt + 1} failed: +${error} + +Recorded steps: +${list} + +The browser is in the state after step ${brokenAt}. Take a snapshot, find out what changed (renamed or moved element, extra dialog, different navigation), and continue the flow from here with test_step until the end, including the assertions. Keep the intent of the original steps. If the change looks like a bug rather than an intended UI change, report_finding. Then call finish_test (complete: true if you reached the end of the flow).`; +} diff --git a/packages/agent/test/analysis.test.ts b/packages/agent/test/analysis.test.ts new file mode 100644 index 0000000..75b1344 --- /dev/null +++ b/packages/agent/test/analysis.test.ts @@ -0,0 +1,85 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { analysisProfile, AnalysisRequestSchema, type KnowledgeBase } from "@claudio/core"; +import { writeKnowledgeTool } from "@claudio/browser"; +import { buildAnalysisPrompt } from "../src/analysis-prompt.js"; +import { readKnowledgeFiles } from "../src/analysis-runner.js"; +import { KNOWLEDGE_INLINE_LIMIT, renderKnowledgeSection } from "../src/prompts.js"; +import { writeKnowledgeFiles } from "../src/runner.js"; + +const tmp = mkdtempSync(join(tmpdir(), "claudio-knowledge-")); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const base: KnowledgeBase = { + commitSha: "abc1234def", + branch: "main", + analyzedAt: "2026-10-01T10:00:00.000Z", + docs: [ + { name: "routes", content: "- `/trips` → src/routes/trips/+page.svelte" }, + { name: "app-map", content: "## Fahrten (/trips)" }, + ], +}; + +const prompt = (overrides: Partial[0]> = {}) => + buildAnalysisPrompt({ + runId: "analysis-x", + request: AnalysisRequestSchema.parse({ repo: "o/r", instanceUrl: "https://staging.example.test", roles: ["dispatcher"] }), + config: { repo: "o/r", roles: { dispatcher: { usernameEnv: "U", passwordEnv: "P", loginPath: "/login", description: "Disponent" } }, extraAllowedHosts: [] }, + repoDir: "/tmp/repo", + branch: "main", + commitSha: "fff0000", + knowledgeDir: "/tmp/run/knowledge", + previous: null, + changedSince: null, + profile: analysisProfile("quick"), + driverPrefix: "", + ...overrides, + }); + +describe("analysis prompt", () => { + it("delegates the code scan and names roles for a first analysis", () => { + const p = prompt(); + expect(p).toContain("code-scanner subagent to map /tmp/repo"); + expect(p).toContain("dispatcher (Disponent)"); + expect(p).not.toContain("Changes since the previous analysis"); + }); + it("lists changed files when updating", () => { + const p = prompt({ previous: base, changedSince: { files: [{ filename: "src/routes/trips/+page.svelte", status: "modified" }], totalCommits: 3, truncated: false } }); + expect(p).toContain("commit abc1234 on main"); + expect(p).toContain("- modified src/routes/trips/+page.svelte"); + expect(p).toContain("Update it rather than starting over"); + }); + it("falls back to the app when there is no checkout", () => { + expect(prompt({ repoDir: null })).toContain("No checkout is available"); + }); +}); + +describe("knowledge files", () => { + it("round-trips documents in canonical order", () => { + const dir = writeKnowledgeFiles(join(tmp, "a"), base); + expect(readKnowledgeFiles(dir).map((d) => d.name)).toEqual(["routes", "app-map"]); + }); + it("write_knowledge replaces and appends", async () => { + const dir = join(tmp, "b"); + const tool = writeKnowledgeTool(dir); + await tool.handler({ doc: "app-map", content: "## A", mode: "replace" }); + await tool.handler({ doc: "app-map", content: "## B", mode: "append" }); + expect(readFileSync(join(dir, "app-map.md"), "utf8")).toBe("## A\n\n## B\n"); + await tool.handler({ doc: "app-map", content: "## C", mode: "replace" }); + expect(readFileSync(join(dir, "app-map.md"), "utf8")).toBe("## C\n"); + }); + it("review section inlines small route index and app map", () => { + const s = renderKnowledgeSection("/run/knowledge", base, "review"); + expect(s).toContain("## routes.md"); + expect(s).toContain("src/routes/trips/+page.svelte"); + expect(s).toContain("/run/knowledge/app-map.md"); + }); + it("review section only references large knowledge bases", () => { + const big = { ...base, docs: [{ name: "app-map" as const, content: "x".repeat(KNOWLEDGE_INLINE_LIMIT + 1) }] }; + const s = renderKnowledgeSection("/run/knowledge", big, "review"); + expect(s).not.toContain("## app-map.md"); + expect(s).toContain("Read routes.md"); + }); +}); diff --git a/packages/agent/test/prompts.test.ts b/packages/agent/test/prompts.test.ts new file mode 100644 index 0000000..deca7d4 --- /dev/null +++ b/packages/agent/test/prompts.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { ReviewRequestSchema, budgetProfile } from "@claudio/core"; +import { buildReviewPrompt, extractAffectedAreas } from "../src/prompts.js"; +import { createBrowserGuard } from "../src/hooks.js"; +import { modelEnv } from "../src/runner.js"; + +describe("extractAffectedAreas", () => { + it("reads the bullet list under the heading", () => { + const s = "Fazit.\n\nAffected areas:\n- Fahrten anlegen\n* Tourenplanung\n\nNicht getestet: nichts"; + expect(extractAffectedAreas(s)).toEqual(["Fahrten anlegen", "Tourenplanung"]); + }); + it("returns empty when missing", () => { + expect(extractAffectedAreas("nothing")).toEqual([]); + }); +}); + +describe("browser guard", () => { + const input = (tool: string, url?: string) => ({ + hook_event_name: "PreToolUse" as const, + tool_name: tool, + tool_input: url ? { url } : {}, + tool_use_id: "t", + session_id: "s", + transcript_path: "", + cwd: "", + }); + it("denies navigation outside the instance host", async () => { + const g = createBrowserGuard({ allowedHosts: ["pr-1.example.test"], maxBrowserSteps: 10 }); + const hook = g.hooks[0]!.hooks[0]!; + const ok = await hook(input("mcp__playwright__browser_navigate", "https://pr-1.example.test/fahrten"), "t", { signal: new AbortController().signal }); + expect(ok).toEqual({}); + const sub = await hook(input("mcp__playwright__browser_navigate", "https://api.pr-1.example.test/x"), "t", { signal: new AbortController().signal }); + expect(sub).toEqual({}); + const bad = await hook(input("mcp__playwright__browser_navigate", "https://evil.test/"), "t", { signal: new AbortController().signal }); + expect((bad as { hookSpecificOutput?: { permissionDecision?: string } }).hookSpecificOutput?.permissionDecision).toBe("deny"); + const js = await hook(input("mcp__playwright__browser_navigate", "javascript:alert(1)"), "t", { signal: new AbortController().signal }); + expect((js as { hookSpecificOutput?: { permissionDecision?: string } }).hookSpecificOutput?.permissionDecision).toBe("deny"); + }); + it("counts steps and denies past the budget, ignoring claudio tools", async () => { + const g = createBrowserGuard({ allowedHosts: ["h"], maxBrowserSteps: 2 }); + const hook = g.hooks[0]!.hooks[0]!; + const sig = { signal: new AbortController().signal }; + await hook(input("mcp__claudio__note"), "t", sig); + await hook(input("mcp__playwright__browser_snapshot"), "t", sig); + await hook(input("mcp__playwright__browser_click"), "t", sig); + expect(g.steps()).toBe(2); + const over = await hook(input("mcp__playwright__browser_click"), "t", sig); + expect((over as { hookSpecificOutput?: { permissionDecision?: string } }).hookSpecificOutput?.permissionDecision).toBe("deny"); + }); +}); + +describe("review procedure", () => { + const input = (tier: "quick" | "standard") => ({ + runId: "r1", + request: ReviewRequestSchema.parse({ repo: "o/r", pr: 1, instanceUrl: "https://pr-1.example.test", budget: tier }), + pr: { owner: "o", repo: "r", number: 1, title: "T", body: "", url: "u", headRef: "f", headSha: "s", baseRef: "main", draft: false, files: [], issues: [] }, + config: null, + repoDir: null, + profile: budgetProfile(tier), + driverPrefix: "", + }); + it("keeps one agent on the quick budget", () => { + const p = buildReviewPrompt(input("quick")); + expect(p).not.toContain("Delegate to ac-verifier"); + expect(p).toContain("Work the todo list"); + }); + it("delegates checks one after the other on the standard budget", () => { + const p = buildReviewPrompt(input("standard")); + expect(p).toContain("Delegate to ac-verifier"); + expect(p).toContain("Delegate to regression-tester"); + expect(p).toContain("never in parallel"); + }); +}); + +describe("model environment", () => { + const server = { PATH: "/bin", ANTHROPIC_API_KEY: "sk-ant-server", CLAUDE_CODE_OAUTH_TOKEN: "oauth", ANTHROPIC_AUTH_TOKEN: "t", ANTHROPIC_BASE_URL: "https://proxy" }; + it("uses only the repository key and drops every other credential", () => { + const env = modelEnv(server, "sk-ant-repo"); + expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-repo"); + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env.PATH).toBe("/bin"); + }); + it("leaves the environment alone without a repository key (CLI)", () => { + expect(modelEnv(server, undefined)).toBe(server); + }); +}); diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/agent/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/browser/.playwright-mcp/page-2026-10-10T13-43-33-002Z.yml b/packages/browser/.playwright-mcp/page-2026-10-10T13-43-33-002Z.yml new file mode 100644 index 0000000..efa2040 --- /dev/null +++ b/packages/browser/.playwright-mcp/page-2026-10-10T13-43-33-002Z.yml @@ -0,0 +1 @@ +- button "Click me" [ref=e2] \ No newline at end of file diff --git a/packages/browser/package.json b/packages/browser/package.json new file mode 100644 index 0000000..390cf7e --- /dev/null +++ b/packages/browser/package.json @@ -0,0 +1,21 @@ +{ + "name": "@claudio/browser", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@claudio/core": "workspace:*", + "@modelcontextprotocol/sdk": "^1.32.1", + "@playwright/mcp": "^0.0.83", + "playwright": "^1.64.0", + "zod": "^4.6.5" + } +} diff --git a/packages/browser/src/capture.ts b/packages/browser/src/capture.ts new file mode 100644 index 0000000..5d565c2 --- /dev/null +++ b/packages/browser/src/capture.ts @@ -0,0 +1,159 @@ +import type { BrowserContext, Page, Request as PwRequest } from "playwright"; + +export type ConsoleEntry = { + seq: number; + ts: string; + level: string; + text: string; + pageUrl: string; + location?: string; +}; + +export type NetworkEntry = { + seq: number; + ts: string; + method: string; + url: string; + resourceType: string; + status?: number; + ok?: boolean; + failed?: string; + durationMs?: number; + requestBody?: string; + responseBody?: string; + pageUrl: string; +}; + +const BODY_LIMIT = 4_000; +const API_TYPES = new Set(["xhr", "fetch"]); + +/** + * Collects console messages and network traffic for every page of a browser + * context, including pages opened later by Playwright MCP. Bodies are only + * kept for XHR/fetch requests and truncated, so the buffer stays small. + */ +export class CaptureBuffer { + readonly console: ConsoleEntry[] = []; + readonly network: NetworkEntry[] = []; + private seq = 0; + private pending = new Map(); + private attachedPages = new WeakSet(); + + attach(context: BrowserContext): void { + context.pages().forEach((p) => this.attachPage(p)); + context.on("page", (p) => this.attachPage(p)); + } + + private attachPage(page: Page): void { + if (this.attachedPages.has(page)) return; + this.attachedPages.add(page); + page.on("console", (msg) => { + const loc = msg.location(); + this.console.push({ + seq: ++this.seq, + ts: new Date().toISOString(), + level: msg.type(), + text: msg.text(), + pageUrl: safeUrl(page), + location: loc.url ? `${loc.url}:${loc.lineNumber}` : undefined, + }); + }); + page.on("pageerror", (err) => { + this.console.push({ + seq: ++this.seq, + ts: new Date().toISOString(), + level: "pageerror", + text: err.message, + pageUrl: safeUrl(page), + }); + }); + page.on("request", (req) => { + const entry: NetworkEntry = { + seq: ++this.seq, + ts: new Date().toISOString(), + method: req.method(), + url: req.url(), + resourceType: req.resourceType(), + pageUrl: safeUrl(page), + }; + if (API_TYPES.has(req.resourceType())) { + const body = req.postData(); + if (body) entry.requestBody = truncate(body, BODY_LIMIT); + } + this.pending.set(req, entry); + this.network.push(entry); + }); + page.on("response", async (res) => { + const entry = this.pending.get(res.request()); + if (!entry) return; + entry.status = res.status(); + entry.ok = res.ok(); + entry.durationMs = Date.now() - Date.parse(entry.ts); + if (API_TYPES.has(entry.resourceType) || !res.ok()) { + try { + const ct = res.headers()["content-type"] ?? ""; + if (/json|text/.test(ct)) entry.responseBody = truncate(await res.text(), BODY_LIMIT); + } catch { + // body may be unavailable (redirect, aborted); keep status only + } + } + this.pending.delete(res.request()); + }); + page.on("requestfailed", (req) => { + const entry = this.pending.get(req); + if (!entry) return; + entry.failed = req.failure()?.errorText ?? "failed"; + entry.ok = false; + this.pending.delete(req); + }); + } + + queryConsole(opts: { level?: string; contains?: string; sinceSeq?: number; limit?: number } = {}): ConsoleEntry[] { + const limit = opts.limit ?? 50; + const minLevel = opts.level ?? "all"; + return this.console + .filter((e) => (opts.sinceSeq ? e.seq > opts.sinceSeq : true)) + .filter((e) => matchesLevel(e.level, minLevel)) + .filter((e) => (opts.contains ? e.text.toLowerCase().includes(opts.contains.toLowerCase()) : true)) + .slice(-limit); + } + + queryNetwork(opts: { urlContains?: string; onlyErrors?: boolean; onlyApi?: boolean; sinceSeq?: number; limit?: number } = {}): NetworkEntry[] { + const limit = opts.limit ?? 50; + return this.network + .filter((e) => (opts.sinceSeq ? e.seq > opts.sinceSeq : true)) + .filter((e) => (opts.onlyApi ? API_TYPES.has(e.resourceType) || e.resourceType === "document" : true)) + .filter((e) => (opts.onlyErrors ? e.failed !== undefined || (e.status !== undefined && e.status >= 400) : true)) + .filter((e) => (opts.urlContains ? e.url.toLowerCase().includes(opts.urlContains.toLowerCase()) : true)) + .slice(-limit); + } + + get lastSeq(): number { + return this.seq; + } + + snapshot(): { console: ConsoleEntry[]; network: NetworkEntry[] } { + return { console: [...this.console], network: [...this.network] }; + } +} + +function matchesLevel(level: string, min: string): boolean { + if (min === "all") return true; + const errorLike = new Set(["error", "pageerror", "assert"]); + const warnLike = new Set(["warning", "warn"]); + if (min === "error") return errorLike.has(level); + if (min === "warning") return errorLike.has(level) || warnLike.has(level); + return true; +} + +function safeUrl(page: Page): string { + try { + return page.url(); + } catch { + return ""; + } +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : text.slice(0, max) + `... [truncated ${text.length - max} chars]`; +} diff --git a/packages/browser/src/chrome-driver.ts b/packages/browser/src/chrome-driver.ts new file mode 100644 index 0000000..77b9cff --- /dev/null +++ b/packages/browser/src/chrome-driver.ts @@ -0,0 +1,37 @@ +import type { BrowserDriver, DriverSession, DriverStartOptions } from "./driver.js"; + +/** + * Live driver: the Claude in Chrome extension drives the developer's visible + * Chrome window through the Claude Code harness (`--chrome`). People can watch + * and step in (log in, solve a CAPTCHA). Documented for the CLI and VS Code; + * whether the Agent SDK forwards the flag is a Phase 0 spike item. + * + * Limitations compared to the Playwright driver: no shared browser handle, so + * Claudio cannot capture console/network itself or run login recipes. The + * agent uses the extension's own tools for screenshots and console reads and + * registers evidence paths with Claudio. + */ +export class ChromeExtensionDriver implements BrowserDriver { + readonly name = "chrome" as const; + + async start(opts: DriverStartOptions): Promise { + const promptPrefix = [ + "Browser driver: Claude in Chrome (live mode). A person is watching the browser window and can step in.", + "Use the Chrome browser tools for navigation, reading pages, clicking, typing and screenshots.", + "When a login page appears, stop and ask the person to log in (say which role), then continue. Never type credentials yourself.", + "Save screenshots to disk and register each one with `mcp__claudio__capture_evidence` (pass the file path). Network traffic is not captured in this mode; say so in findings where it matters.", + `Only navigate within ${opts.instanceUrl}.`, + ].join("\n"); + + return { + name: "chrome", + mcpServers: {}, + allowedTools: ["mcp__claudio__*", "mcp__claude-in-chrome__*", "mcp__chrome__*"], + disallowedTools: [], + extraArgs: { chrome: null }, + promptPrefix, + attachment: null, + close: async () => undefined, + }; + } +} diff --git a/packages/browser/src/driver.ts b/packages/browser/src/driver.ts new file mode 100644 index 0000000..645ef9e --- /dev/null +++ b/packages/browser/src/driver.ts @@ -0,0 +1,52 @@ +import type { Browser, BrowserContext, Page } from "playwright"; +import type { DriverName } from "@claudio/core"; +import type { CaptureBuffer } from "./capture.js"; + +/** Structurally compatible with the Agent SDK's McpStdioServerConfig. */ +export type StdioServerSpec = { + type?: "stdio"; + command: string; + args?: string[]; + env?: Record; + alwaysLoad?: boolean; +}; + +/** Claudio's own handle on the browser that the agent drives. */ +export type BrowserAttachment = { + browser: Browser; + context: BrowserContext; + capture: CaptureBuffer; + cdpEndpoint: string; + /** The page the agent most recently interacted with (best effort: last page in the context). */ + activePage(): Page | undefined; +}; + +export type DriverSession = { + name: DriverName; + mcpServers: Record; + /** Tool patterns the agent may call without a permission prompt. */ + allowedTools: string[]; + /** Tools removed from the agent entirely. */ + disallowedTools: string[]; + /** Extra CLI flags for the Claude Code harness (e.g. `--chrome`). */ + extraArgs?: Record; + /** Driver-specific instructions prepended to the task prompt. */ + promptPrefix: string; + attachment: BrowserAttachment | null; + close(): Promise; +}; + +export type DriverStartOptions = { + runDir: string; + instanceUrl: string; + headed: boolean; + /** Explicit Chromium binary; defaults to Playwright's bundled browser or CLAUDIO_CHROMIUM_PATH. */ + chromiumPath?: string; + viewport?: { width: number; height: number }; + language?: "de" | "en"; +}; + +export interface BrowserDriver { + readonly name: DriverName; + start(opts: DriverStartOptions): Promise; +} diff --git a/packages/browser/src/human.ts b/packages/browser/src/human.ts new file mode 100644 index 0000000..7e33ce4 --- /dev/null +++ b/packages/browser/src/human.ts @@ -0,0 +1,84 @@ +import type { Page } from "playwright"; +import type { BrowserAttachment } from "./driver.js"; + +/** + * Hands the browser to a person for a moment (CAPTCHA, bot check, 2FA prompt). + * The worker implements it: it records the request, applies the person's clicks + * and keystrokes, and resolves when they press "continue". + */ +export type HumanOutcome = "resolved" | "cancelled" | "timeout"; +export type HumanBridge = { wait(reason: string): Promise }; +/** Created per run once the browser exists. */ +export type HumanBridgeFactory = (attachment: BrowserAttachment) => HumanBridge; + +/** Input a person sends from the live view. Coordinates are relative to the viewport (0..1). */ +export type HumanInput = + | { type: "click"; x: number; y: number } + | { type: "type"; text: string } + | { type: "key"; key: string } + | { type: "scroll"; dy: number }; + +const ALLOWED_KEYS = new Set(["Enter", "Tab", "Backspace", "Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Space"]); + +export async function applyHumanInput(page: Page, input: HumanInput): Promise { + const vp = page.viewportSize() ?? (await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }))); + switch (input.type) { + case "click": + await page.mouse.click(Math.round(clamp01(input.x) * vp.width), Math.round(clamp01(input.y) * vp.height)); + return; + case "type": + await page.keyboard.type(input.text.slice(0, 500), { delay: 30 }); + return; + case "key": + if (ALLOWED_KEYS.has(input.key)) await page.keyboard.press(input.key === "Space" ? " " : input.key); + return; + case "scroll": + await page.mouse.wheel(0, Math.max(-2000, Math.min(2000, input.dy))); + return; + } +} + +const clamp01 = (v: number) => Math.max(0, Math.min(1, Number.isFinite(v) ? v : 0)); + +const CAPTCHA_FRAMES = [ + { pattern: /google\.com\/recaptcha|recaptcha\.net|\/recaptcha\/api2\//i, name: "reCAPTCHA" }, + { pattern: /hcaptcha\.com/i, name: "hCaptcha" }, + { pattern: /challenges\.cloudflare\.com/i, name: "Cloudflare Turnstile" }, + { pattern: /arkoselabs\.com|funcaptcha/i, name: "Arkose/FunCaptcha" }, +]; + +const CAPTCHA_TEXT = [ + /i'?m not a robot/i, + /ich bin kein roboter/i, + /unusual traffic from your computer/i, + /ungewöhnliche[nr]? (daten)?verkehr/i, + /verify (that )?you are (a )?human/i, + /bestätigen sie, dass sie ein mensch sind/i, + /checking if the site connection is secure/i, +]; + +/** + * Recognises a CAPTCHA or bot check on the current page. Returns a short + * description, or null. Cheap enough to run after every browser action. + */ +export async function detectCaptcha(page: Page | undefined): Promise { + if (!page || page.isClosed()) return null; + try { + const url = page.url(); + if (/google\.[a-z.]+\/sorry\//i.test(url)) return "Google-Bot-Prüfung (ungewöhnlicher Datenverkehr)"; + for (const frame of page.frames()) { + const hit = CAPTCHA_FRAMES.find((c) => c.pattern.test(frame.url())); + if (hit) { + // Invisible reCAPTCHA badges are everywhere; only count a visible challenge frame. + const el = await frame.frameElement().catch(() => null); + const box = el ? await el.boundingBox().catch(() => null) : null; + if (box && box.width > 40 && box.height > 40) return `${hit.name} auf ${new URL(url).host}`; + } + } + const text = await page.locator("body").innerText({ timeout: 1500 }).catch(() => ""); + const m = CAPTCHA_TEXT.find((re) => re.test(text.slice(0, 5000))); + return m ? `Bot-Prüfung auf ${new URL(url).host}` : null; + } catch { + return null; + } +} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts new file mode 100644 index 0000000..704afea --- /dev/null +++ b/packages/browser/src/index.ts @@ -0,0 +1,20 @@ +export * from "./driver.js"; +export * from "./playwright-driver.js"; +export * from "./chrome-driver.js"; +export * from "./capture.js"; +export * from "./run-store.js"; +export * from "./login.js"; +export * from "./live-view.js"; +export * from "./steps.js"; +export * from "./human.js"; +export * from "./tools/test-tools.js"; +export * from "./tools/definitions.js"; + +import type { DriverName } from "@claudio/core"; +import { ChromeExtensionDriver } from "./chrome-driver.js"; +import type { BrowserDriver } from "./driver.js"; +import { PlaywrightDriver } from "./playwright-driver.js"; + +export function createDriver(name: DriverName): BrowserDriver { + return name === "chrome" ? new ChromeExtensionDriver() : new PlaywrightDriver(); +} diff --git a/packages/browser/src/live-view.ts b/packages/browser/src/live-view.ts new file mode 100644 index 0000000..5acdd9b --- /dev/null +++ b/packages/browser/src/live-view.ts @@ -0,0 +1,109 @@ +import { renameSync, rmSync, writeFileSync } from "node:fs"; +import type { CDPSession, Page } from "playwright"; +import type { BrowserAttachment } from "./driver.js"; + +export type LiveView = { stop(): Promise }; + +export type LiveViewOptions = { + /** Upper bound for writes to disk; Chromium only sends frames when the page changes. */ + maxFps?: number; + quality?: number; + maxWidth?: number; + maxHeight?: number; + /** Take a real screenshot when the screencast has sent nothing for this long (ms). */ + refreshMs?: number; +}; + +/** + * Mirrors what the agent's browser shows into one JPEG file (Chromium screencast + * over CDP). The file is replaced atomically, so a reader never sees half a frame. + * Follows the active tab. Costs no model tokens. + */ +export function startLiveView(attachment: BrowserAttachment, file: string, opts: LiveViewOptions = {}): LiveView { + const minInterval = 1000 / (opts.maxFps ?? 4); + const tmp = `${file}.tmp`; + let session: CDPSession | null = null; + let page: Page | null = null; + let lastWrite = 0; + let stopped = false; + + const attach = async (next: Page) => { + if (stopped || next === page) return; + await detach(); + page = next; + try { + const s = await attachment.context.newCDPSession(next); + session = s; + s.on("Page.screencastFrame", (frame: { data: string; sessionId: number }) => { + s.send("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => undefined); + const now = Date.now(); + if (now - lastWrite < minInterval) return; + lastWrite = now; + try { + writeFileSync(tmp, Buffer.from(frame.data, "base64")); + renameSync(tmp, file); + } catch { + // disk hiccup: skip this frame + } + }); + await s.send("Page.startScreencast", { + format: "jpeg", + quality: opts.quality ?? 60, + maxWidth: opts.maxWidth ?? 1280, + maxHeight: opts.maxHeight ?? 800, + everyNthFrame: 1, + }); + } catch { + // page closed before the session was ready; the poll below picks the next one + session = null; + page = null; + } + }; + + const detach = async () => { + const s = session; + session = null; + page = null; + if (!s) return; + await s.send("Page.stopScreencast").catch(() => undefined); + await s.detach().catch(() => undefined); + }; + + // The agent opens and closes tabs through its own CDP connection; follow the newest one. + const poll = setInterval(() => { + const active = attachment.activePage(); + if (active && active !== page) void attach(active); + }, 1000); + + // The screencast misses changes inside cross-origin iframes (reCAPTCHA, payment widgets), + // so a still page can show a stale frame. A real screenshot includes them; take one when + // the screencast has been quiet for a moment. + let shooting = false; + const refresh = setInterval(() => { + if (shooting || Date.now() - lastWrite < (opts.refreshMs ?? 700)) return; + const current = attachment.activePage(); + if (!current || current.isClosed()) return; + shooting = true; + current + .screenshot({ type: "jpeg", quality: opts.quality ?? 60, timeout: 3000, animations: "allow", caret: "initial" }) + .then((buf) => { + lastWrite = Date.now(); + writeFileSync(tmp, buf); + renameSync(tmp, file); + }) + .catch(() => undefined) + .finally(() => (shooting = false)); + }, 250); + const first = attachment.activePage(); + if (first) void attach(first); + + return { + async stop() { + stopped = true; + clearInterval(poll); + clearInterval(refresh); + await detach(); + rmSync(tmp, { force: true }); + }, + }; +} diff --git a/packages/browser/src/login.ts b/packages/browser/src/login.ts new file mode 100644 index 0000000..9b2de2a --- /dev/null +++ b/packages/browser/src/login.ts @@ -0,0 +1,131 @@ +import type { BrowserContext, Page } from "playwright"; +import type { Credentials, RoleConfig } from "@claudio/core"; + +export type LoginResult = { ok: true; finalUrl: string; page: Page } | { ok: false; error: string; finalUrl?: string }; + +/** + * Logs a role in. Uses the explicit recipe when the config has one, otherwise + * an adaptive heuristic that works for most username/password forms. + * Credentials never appear in returned messages. + */ +export async function loginWithRole( + context: BrowserContext, + instanceUrl: string, + role: RoleConfig, + creds: Credentials, +): Promise { + const page = context.pages().find((p) => !p.isClosed()) ?? (await context.newPage()); + try { + if (role.recipe && role.recipe.length > 0) { + await runRecipe(page, instanceUrl, role, creds); + } else { + await autoLogin(page, instanceUrl, role, creds); + } + await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); + const finalUrl = page.url(); + if (role.successUrlContains && !finalUrl.includes(role.successUrlContains)) { + return { ok: false, error: `Login did not reach a URL containing "${role.successUrlContains}"`, finalUrl }; + } + if (looksLikeLoginPage(finalUrl, role)) { + const err = await visibleErrorText(page); + return { ok: false, error: `Still on the login page after submitting${err ? `: ${err}` : ""}`, finalUrl }; + } + return { ok: true, finalUrl, page }; + } catch (e) { + return { ok: false, error: scrub((e as Error).message, creds), finalUrl: safeUrl(page) }; + } +} + +async function runRecipe(page: Page, instanceUrl: string, role: RoleConfig, creds: Credentials): Promise { + const subst = (v: string) => v.replace(/\$USER\b/g, creds.username).replace(/\$PASSWORD\b/g, creds.password); + for (const step of role.recipe ?? []) { + switch (step.action) { + case "goto": + await page.goto(new URL(step.url, instanceUrl).toString(), { waitUntil: "domcontentloaded" }); + break; + case "fill": + await page.locator(step.selector).first().fill(subst(step.value)); + break; + case "click": + await page.locator(step.selector).first().click(); + break; + case "press": + await page.keyboard.press(step.key); + break; + case "waitForUrl": + await page.waitForURL((u) => u.toString().includes(step.pattern), { timeout: 20_000 }); + break; + case "waitForSelector": + await page.locator(step.selector).first().waitFor({ timeout: 20_000 }); + break; + } + } +} + +async function autoLogin(page: Page, instanceUrl: string, role: RoleConfig, creds: Credentials): Promise { + await page.goto(new URL(role.loginPath, instanceUrl).toString(), { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => undefined); + + const password = page.locator('input[type="password"]').first(); + await password.waitFor({ timeout: 15_000 }); + + const userCandidates = [ + 'input[autocomplete="username"]', + 'input[type="email"]', + 'input[name*="user" i]', + 'input[name*="mail" i]', + 'input[name*="login" i]', + 'input[type="text"]', + ]; + let filledUser = false; + for (const sel of userCandidates) { + const loc = page.locator(sel).first(); + if ((await loc.count()) > 0 && (await loc.isVisible().catch(() => false))) { + await loc.fill(creds.username); + filledUser = true; + break; + } + } + if (!filledUser) throw new Error("Could not find a username/email field on the login page"); + await password.fill(creds.password); + + const submit = page.locator('button[type="submit"], input[type="submit"], form button').first(); + const startUrl = page.url(); + if ((await submit.count()) > 0) { + await submit.click(); + } else { + await password.press("Enter"); + } + await Promise.race([ + page.waitForURL((u) => u.toString() !== startUrl, { timeout: 20_000 }), + page.waitForLoadState("networkidle", { timeout: 20_000 }), + ]).catch(() => undefined); +} + +function looksLikeLoginPage(url: string, role: RoleConfig): boolean { + const loginPath = role.loginPath.replace(/\/+$/, ""); + return loginPath.length > 1 && new URL(url).pathname.replace(/\/+$/, "") === loginPath; +} + +async function visibleErrorText(page: Page): Promise { + const candidates = page.locator('[role="alert"], .error, .alert, [class*="error" i], [data-testid*="error" i]'); + const n = Math.min(await candidates.count(), 3); + const texts: string[] = []; + for (let i = 0; i < n; i++) { + const t = (await candidates.nth(i).innerText().catch(() => "")).trim(); + if (t) texts.push(t.slice(0, 200)); + } + return texts.join(" | "); +} + +function scrub(text: string, creds: Credentials): string { + return text.split(creds.password).join("***").split(creds.username).join(""); +} + +function safeUrl(page: Page): string | undefined { + try { + return page.url(); + } catch { + return undefined; + } +} diff --git a/packages/browser/src/playwright-driver.ts b/packages/browser/src/playwright-driver.ts new file mode 100644 index 0000000..57cb422 --- /dev/null +++ b/packages/browser/src/playwright-driver.ts @@ -0,0 +1,133 @@ +import { createRequire } from "node:module"; +import { createServer } from "node:net"; +import { dirname, join } from "node:path"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { chromium, type Browser } from "playwright"; +import { CaptureBuffer } from "./capture.js"; +import type { BrowserAttachment, BrowserDriver, DriverSession, DriverStartOptions } from "./driver.js"; + +const require = createRequire(import.meta.url); + +/** + * Unattended driver. Claudio launches Chromium itself and lets Playwright MCP + * attach over CDP, so both the agent (via MCP) and Claudio's own tools + * (login, evidence, console/network capture) share one browser. + */ +export class PlaywrightDriver implements BrowserDriver { + readonly name = "playwright" as const; + + async start(opts: DriverStartOptions): Promise { + const port = await freePort(); + const executablePath = opts.chromiumPath ?? process.env.CLAUDIO_CHROMIUM_PATH; + let launched: Browser; + try { + launched = await chromium.launch({ + headless: !opts.headed, + executablePath, + args: [`--remote-debugging-port=${port}`, "--remote-debugging-address=127.0.0.1"], + }); + } catch (e) { + const msg = (e as Error).message; + if (/Executable doesn't exist/i.test(msg)) { + throw new Error( + `Chromium not found${executablePath ? ` at ${executablePath}` : ""}. Run "pnpm exec playwright install chromium" or set CLAUDIO_CHROMIUM_PATH to a Chromium/Chrome binary.`, + ); + } + throw e; + } + const cdpEndpoint = `http://127.0.0.1:${port}`; + + const attached = await chromium.connectOverCDP(cdpEndpoint); + const context = attached.contexts()[0] ?? (await attached.newContext()); + const capture = new CaptureBuffer(); + capture.attach(context); + if (opts.viewport) { + context.on("page", (p) => p.setViewportSize(opts.viewport!).catch(() => undefined)); + } + + let tracing = false; + try { + await context.tracing.start({ screenshots: true, snapshots: true, sources: false }); + tracing = true; + } catch { + // tracing is best effort over CDP + } + + const attachment: BrowserAttachment = { + browser: attached, + context, + capture, + cdpEndpoint, + activePage: () => { + const pages = context.pages().filter((p) => !p.isClosed()); + return pages[pages.length - 1]; + }, + }; + + const mcpOutputDir = join(opts.runDir, "mcp-output"); + mkdirSync(mcpOutputDir, { recursive: true }); + // The package exports only its index; the CLI sits next to it. + const cliPath = join(dirname(require.resolve("@playwright/mcp")), "cli.js"); + const args = [ + cliPath, + "--cdp-endpoint", + cdpEndpoint, + "--caps", + "devtools", + "--output-dir", + mcpOutputDir, + "--image-responses", + "allow", + "--timeout-action", + "8000", + "--timeout-settle", + "800", + ]; + if (opts.viewport) args.push("--viewport-size", `${opts.viewport.width}x${opts.viewport.height}`); + + const promptPrefix = [ + "Browser driver: Playwright MCP (headless, unattended).", + "Use `mcp__playwright__browser_snapshot` to read the page (accessibility tree with element refs); take screenshots only when you need visual judgement or evidence.", + "Use `mcp__claudio__capture_evidence` for every observation you will report, and `mcp__claudio__get_console` / `mcp__claudio__get_network` after each flow to check for errors.", + `Only navigate within ${opts.instanceUrl}. Login is done with \`mcp__claudio__login_as\`, never by typing credentials yourself.`, + ].join("\n"); + + return { + name: "playwright", + mcpServers: { + playwright: { type: "stdio", command: process.execPath, args, alwaysLoad: true }, + }, + allowedTools: ["mcp__playwright__*", "mcp__claudio__*"], + disallowedTools: [ + "mcp__playwright__browser_run_code_unsafe", + "mcp__playwright__browser_file_upload", + "mcp__playwright__browser_start_recording", + "mcp__playwright__browser_stop_recording", + ], + promptPrefix, + attachment, + close: async () => { + const snap = capture.snapshot(); + writeFileSync(join(opts.runDir, "console.json"), JSON.stringify(snap.console, null, 2)); + writeFileSync(join(opts.runDir, "network.json"), JSON.stringify(snap.network, null, 2)); + if (tracing) { + await context.tracing.stop({ path: join(opts.runDir, "trace.zip") }).catch(() => undefined); + } + await attached.close().catch(() => undefined); + await launched.close().catch(() => undefined); + }, + }; + } +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + srv.close(() => (port ? resolve(port) : reject(new Error("no free port")))); + }); + srv.on("error", reject); + }); +} diff --git a/packages/browser/src/run-store.ts b/packages/browser/src/run-store.ts new file mode 100644 index 0000000..822aa59 --- /dev/null +++ b/packages/browser/src/run-store.ts @@ -0,0 +1,120 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs"; +import { join } from "node:path"; +import { + AcceptanceCriterionResultSchema, + EvidenceSchema, + FindingSchema, + TodoItemSchema, + nowIso, + type AcceptanceCriterionResult, + type Evidence, + type Finding, + type TodoItem, +} from "@claudio/core"; +import { z } from "zod"; + +/** + * File-backed state of one run. Every write goes straight to disk, so a + * crashed or aborted run still leaves findings, evidence and notes behind. + */ +export class RunStore { + readonly evidenceDir: string; + private counters = { finding: 0, evidence: 0, todo: 0 }; + + constructor(readonly runDir: string) { + mkdirSync(runDir, { recursive: true }); + this.evidenceDir = join(runDir, "evidence"); + mkdirSync(this.evidenceDir, { recursive: true }); + this.counters.finding = this.findings().length; + this.counters.evidence = this.evidence().length; + this.counters.todo = this.todos().length; + } + + private file(name: string): string { + return join(this.runDir, name); + } + + private readJson(name: string, schema: z.ZodType): T[] { + const f = this.file(name); + if (!existsSync(f)) return []; + const raw = JSON.parse(readFileSync(f, "utf8")); + return z.array(schema).parse(raw); + } + + private writeJson(name: string, data: unknown): void { + writeFileSync(this.file(name), JSON.stringify(data, null, 2)); + } + + findings(): Finding[] { + return this.readJson("findings.json", FindingSchema); + } + + addFinding(input: Omit): Finding { + const finding: Finding = { ...input, id: `F${++this.counters.finding}`, createdAt: nowIso() }; + this.writeJson("findings.json", [...this.findings(), finding]); + return finding; + } + + evidence(): Evidence[] { + return this.readJson("evidence.json", EvidenceSchema); + } + + nextEvidenceId(): string { + return `E${++this.counters.evidence}`; + } + + addEvidence(input: Omit): Evidence { + const ev: Evidence = { ...input, createdAt: nowIso() }; + this.writeJson("evidence.json", [...this.evidence(), ev]); + return ev; + } + + acResults(): AcceptanceCriterionResult[] { + return this.readJson("acceptance.json", AcceptanceCriterionResultSchema); + } + + recordAcResult(input: Omit): AcceptanceCriterionResult { + const existing = this.acResults().filter((a) => a.criterion !== input.criterion); + const result: AcceptanceCriterionResult = { ...input, createdAt: nowIso() }; + this.writeJson("acceptance.json", [...existing, result]); + return result; + } + + addNote(text: string): void { + appendFileSync(this.file("notes.md"), `- ${nowIso()} ${text.trim().replace(/\n/g, "\n ")}\n`); + } + + notes(): string { + const f = this.file("notes.md"); + return existsSync(f) ? readFileSync(f, "utf8") : ""; + } + + todos(): TodoItem[] { + return this.readJson("todo.json", TodoItemSchema); + } + + todoAdd(items: string[]): TodoItem[] { + const added = items.map((text) => ({ id: ++this.counters.todo, text, status: "open" as const })); + this.writeJson("todo.json", [...this.todos(), ...added]); + return added; + } + + todoNext(): TodoItem | undefined { + return this.todos().find((t) => t.status === "open"); + } + + todoDone(id: number, result: string, status: "done" | "skipped" = "done"): TodoItem | undefined { + const all = this.todos(); + const item = all.find((t) => t.id === id); + if (!item) return undefined; + item.status = status; + item.result = result; + this.writeJson("todo.json", all); + return item; + } + + /** Arbitrary JSON side files (pr-context.json, cost.json, ...). */ + writeFile(name: string, content: string | object): void { + writeFileSync(this.file(name), typeof content === "string" ? content : JSON.stringify(content, null, 2)); + } +} diff --git a/packages/browser/src/steps.ts b/packages/browser/src/steps.ts new file mode 100644 index 0000000..5277dc8 --- /dev/null +++ b/packages/browser/src/steps.ts @@ -0,0 +1,206 @@ +import { join } from "node:path"; +import type { Locator, Page } from "playwright"; +import { describeStep, hostAllowed, resolveCredentials, resolveStepTemplates, type RoleConfig, type Target, type TestStep } from "@claudio/core"; +import type { BrowserAttachment } from "./driver.js"; +import { loginWithRole } from "./login.js"; +import type { RunStore } from "./run-store.js"; + +export type StepContext = { + attachment: BrowserAttachment; + instanceUrl: string; + roles: Record; + env: NodeJS.ProcessEnv; + /** Short unique tag of this run, substituted for {{run}}. */ + runTag: string; + store: RunStore; + /** Hosts besides the instance the test may open (repo and test settings, e.g. an auth provider). */ + extraAllowedHosts?: string[]; + /** The test may navigate anywhere. */ + allowAnyHost?: boolean; + /** Console/network sequence number at the start of the test, for expect_no_errors. */ + baselineSeq: number; + actionTimeoutMs?: number; + expectTimeoutMs?: number; +}; + +export type StepOutcome = { + ok: boolean; + error?: string; + /** The failure is an assertion (the app behaves differently), not a missing element. */ + assertionFailed?: boolean; + durationMs: number; +}; + +export function allowedHosts(ctx: Pick): string[] { + return [new URL(ctx.instanceUrl).hostname, ...(ctx.extraAllowedHosts ?? [])]; +} + +/** + * Blocks page navigations (links, redirects, form posts) to hosts the test may + * not open, so a click cannot leave the instance either. Subresources such as + * fonts or CDN scripts are not affected. + */ +export async function guardNavigation(ctx: StepContext, onBlocked: (url: string) => void): Promise { + if (ctx.allowAnyHost) return; + const allowed = allowedHosts(ctx); + await ctx.attachment.context.route("**/*", (route) => { + const req = route.request(); + if (req.isNavigationRequest() && req.frame().parentFrame() === null) { + let host = ""; + try { + host = new URL(req.url()).hostname; + } catch { + return route.continue(); + } + if (host && !hostAllowed(host, allowed)) { + onBlocked(req.url()); + return route.abort("blockedbyclient"); + } + } + return route.continue(); + }); +} + +/** The page the test drives: the newest open tab, or a fresh one. */ +export async function testPage(attachment: BrowserAttachment): Promise { + return attachment.activePage() ?? (await attachment.context.newPage()); +} + +export function locate(page: Page, t: Target): Locator { + if ("role" in t) return page.getByRole(t.role as Parameters[0], { name: t.name, exact: t.exact }); + if ("label" in t) return page.getByLabel(t.label, { exact: t.exact }); + if ("placeholder" in t) return page.getByPlaceholder(t.placeholder); + if ("text" in t) return page.getByText(t.text, { exact: t.exact }); + if ("testId" in t) return page.getByTestId(t.testId); + return page.locator(t.css); +} + +/** + * Executes one step with Playwright directly (no model). Recording and replay + * both go through here, so a recorded step replays exactly as it ran. + */ +export async function executeStep(recorded: TestStep, ctx: StepContext): Promise { + const started = Date.now(); + const actionTimeout = ctx.actionTimeoutMs ?? 8_000; + const expectTimeout = ctx.expectTimeoutMs ?? 5_000; + // Templates ({{run}}, {{date:…}}) may appear in values, texts and target names. + const step = resolveStepTemplates(recorded, { runTag: ctx.runTag }); + const done = (ok: boolean, error?: string, assertionFailed = false): StepOutcome => ({ ok, error, assertionFailed, durationMs: Date.now() - started }); + + try { + const page = await testPage(ctx.attachment); + switch (step.action) { + case "login": { + const role = ctx.roles[step.role]; + if (!role) return done(false, `Unknown role "${step.role}". Configured: ${Object.keys(ctx.roles).join(", ") || "none"}.`); + const creds = resolveCredentials(role, ctx.env); + if (!creds) return done(false, `Credentials for role "${step.role}" are missing.`); + await ctx.attachment.context.clearCookies().catch(() => undefined); + const r = await loginWithRole(ctx.attachment.context, ctx.instanceUrl, role, creds); + return r.ok ? done(true) : done(false, `Login failed: ${r.error}`); + } + case "goto": { + const url = new URL(step.path, ctx.instanceUrl); + const allowed = allowedHosts(ctx); + if (!ctx.allowAnyHost && !hostAllowed(url.hostname, allowed)) { + return done(false, `${url.host} is not allowed for this test (allowed: ${allowed.join(", ")}). More hosts can be allowed in the test or repository settings.`); + } + await page.goto(url.toString(), { timeout: actionTimeout * 2 }); + await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); + return done(true); + } + case "click": + await locate(page, step.target).click({ timeout: actionTimeout }); + await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); + return done(true); + case "fill": + await locate(page, step.target).fill(step.value, { timeout: actionTimeout }); + return done(true); + case "select": + await locate(page, step.target).selectOption({ label: step.value }, { timeout: actionTimeout }).catch(async () => { + await locate(page, step.target).selectOption(step.value, { timeout: actionTimeout }); + }); + return done(true); + case "check": + await locate(page, step.target).check({ timeout: actionTimeout }); + return done(true); + case "uncheck": + await locate(page, step.target).uncheck({ timeout: actionTimeout }); + return done(true); + case "press": + if (step.target) await locate(page, step.target).press(step.key, { timeout: actionTimeout }); + else await page.keyboard.press(step.key); + await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); + return done(true); + case "expect_visible": + try { + await locate(page, step.target).first().waitFor({ state: "visible", timeout: expectTimeout }); + return done(true); + } catch { + return done(false, `Not visible: ${describeStep(step)}`, true); + } + case "expect_hidden": + try { + await locate(page, step.target).first().waitFor({ state: "hidden", timeout: expectTimeout }); + return done(true); + } catch { + return done(false, `Still visible: ${describeStep(step)}`, true); + } + case "expect_text": { + const text = step.text; + const scope = step.target ? locate(page, step.target).first() : page.locator("body"); + const deadline = Date.now() + expectTimeout; + let seen = ""; + while (Date.now() < deadline) { + seen = (await scope.innerText({ timeout: expectTimeout }).catch(() => "")) ?? ""; + if (seen.includes(text)) return done(true); + await page.waitForTimeout(250); + } + return done(false, `Text "${text}" not found${step.target ? " in the target" : " on the page"}. Visible text starts with: ${seen.replace(/\s+/g, " ").slice(0, 200)}`, true); + } + case "expect_url": { + const want = step.contains; + const deadline = Date.now() + expectTimeout; + while (Date.now() < deadline) { + if (page.url().includes(want)) return done(true); + await page.waitForTimeout(200); + } + return done(false, `URL ${page.url()} does not contain "${want}".`, true); + } + case "expect_no_errors": { + const capture = ctx.attachment.capture; + const consoleErrors = capture.queryConsole({ level: "error", sinceSeq: ctx.baselineSeq, limit: 5 }); + const networkErrors = capture.queryNetwork({ onlyErrors: true, onlyApi: true, sinceSeq: ctx.baselineSeq, limit: 5 }); + if (consoleErrors.length === 0 && networkErrors.length === 0) return done(true); + const parts = [ + ...consoleErrors.map((e) => `console: ${e.text.slice(0, 160)}`), + ...networkErrors.map((e) => `${e.method} ${e.url} → ${e.failed ?? e.status}`), + ]; + return done(false, `Errors during the test: ${parts.join("; ")}`, true); + } + case "screenshot": { + const id = ctx.store.nextEvidenceId(); + const safe = step.label.replace(/[^\w.-]+/g, "_").slice(0, 60); + const file = join(ctx.store.evidenceDir, `${id}-${safe}.png`); + await page.screenshot({ path: file }); + ctx.store.addEvidence({ id, kind: "screenshot", label: step.label, path: `evidence/${id}-${safe}.png`, pageUrl: page.url() }); + return done(true); + } + } + } catch (e) { + return done(false, cleanError((e as Error).message)); + } +} + +/** Playwright errors carry call logs; keep the first lines, which say what went wrong. */ +function cleanError(message: string): string { + return message.split("\n").slice(0, 3).join(" ").replace(/\s+/g, " ").slice(0, 400); +} + +/** Accessibility tree of the current page (roles and names), for choosing step targets. */ +export async function ariaSnapshot(attachment: BrowserAttachment, maxChars = 15_000): Promise { + const page = await testPage(attachment); + const tree = await page.locator("body").ariaSnapshot({ timeout: 5_000 }).catch((e: Error) => `(snapshot failed: ${e.message})`); + const head = `URL: ${page.url()}\nTitle: ${await page.title().catch(() => "")}\n\n`; + return head + (tree.length > maxChars ? tree.slice(0, maxChars) + "\n… (truncated)" : tree); +} diff --git a/packages/browser/src/tools/definitions.ts b/packages/browser/src/tools/definitions.ts new file mode 100644 index 0000000..8675305 --- /dev/null +++ b/packages/browser/src/tools/definitions.ts @@ -0,0 +1,297 @@ +import { join, relative, isAbsolute } from "node:path"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { z } from "zod"; +import { + AcStatusSchema, + FindingCategorySchema, + KNOWLEDGE_DOCS, + KnowledgeDocNameSchema, + SeveritySchema, + resolveCredentials, + type DriverName, + type RoleConfig, +} from "@claudio/core"; +import type { BrowserAttachment } from "../driver.js"; +import { detectCaptcha, type HumanBridge } from "../human.js"; +import { loginWithRole } from "../login.js"; +import type { RunStore } from "../run-store.js"; + +export type ToolContext = { + runDir: string; + store: RunStore; + attachment: BrowserAttachment | null; + instanceUrl: string; + roles: Record; + driver: DriverName; + env?: NodeJS.ProcessEnv; + /** Repository analysis only: directory the knowledge documents are written to. Enables write_knowledge. */ + knowledgeDir?: string; + /** A person who can take over the browser for a moment (CAPTCHA, bot check). Enables request_human_help. */ + human?: HumanBridge; +}; + +/** A tool definition that both the Agent SDK adapter and the stdio MCP server can consume. */ +export type ClaudioTool = { + name: string; + description: string; + schema: S; + readOnly?: boolean; + handler: (args: z.infer>) => Promise; +}; + +export function def(t: ClaudioTool): ClaudioTool { + return t; +} + +export function createClaudioTools(ctx: ToolContext): ClaudioTool[] { + const { store } = ctx; + const roleNames = Object.keys(ctx.roles); + + const loginAs = def({ + name: "login_as", + description: `Log the browser in as one of the configured application roles (${roleNames.join(", ") || "none configured"}). Credentials stay on the host; you only get the result. Call this before testing anything that needs authentication, and again to switch roles.`, + schema: { role: z.string().describe("Role name as configured for this repository") }, + handler: async ({ role }) => { + const cfg = ctx.roles[role]; + if (!cfg) return `Unknown role "${role}". Available roles: ${roleNames.join(", ") || "none"}.`; + if (!ctx.instanceUrl) return "No instance URL configured (CLAUDIO_INSTANCE_URL). Ask the person for the URL of the app under test."; + if (ctx.driver === "chrome" || !ctx.attachment) { + return `Live mode: ask the person watching to log in as "${role}"${cfg.description ? ` (${cfg.description})` : ""} in the browser window, then continue.`; + } + const creds = resolveCredentials(cfg, ctx.env ?? process.env); + if (!creds) return `Credentials for role "${role}" are missing (env vars ${cfg.usernameEnv} / ${cfg.passwordEnv}). Report this as blocked and continue with what you can test without login.`; + await ctx.attachment.context.clearCookies().catch(() => undefined); + const result = await loginWithRole(ctx.attachment.context, ctx.instanceUrl, cfg, creds); + if (!result.ok) { + store.addNote(`login_as(${role}) failed: ${result.error}`); + return `Login as "${role}" failed: ${result.error}${result.finalUrl ? ` (at ${result.finalUrl})` : ""}. You may inspect the login page yourself, but do not type credentials.`; + } + store.addNote(`login_as(${role}) ok → ${result.finalUrl}`); + return `Logged in as "${role}"${cfg.description ? ` (${cfg.description})` : ""}. Current URL: ${result.finalUrl}. Take a fresh snapshot before continuing.`; + }, + }); + + const captureEvidence = def({ + name: "capture_evidence", + description: + "Record evidence for something you observed: takes a screenshot of the current page (Playwright driver) or registers a screenshot you already saved (pass `path`). Returns an evidence id to reference in findings and acceptance results.", + schema: { + label: z.string().describe("Short label, e.g. 'Fahrt anlegen: Validierungsfehler'"), + note: z.string().optional().describe("What the evidence shows"), + path: z.string().optional().describe("Path of an existing screenshot file (live mode); omit to capture now"), + }, + handler: async ({ label, note, path }) => { + const id = store.nextEvidenceId(); + const safe = label.replace(/[^\w.-]+/g, "_").slice(0, 60); + if (path) { + const abs = isAbsolute(path) ? path : join(ctx.runDir, path); + if (!existsSync(abs)) return `File not found: ${abs}. Save the screenshot first, then register it.`; + store.addEvidence({ id, kind: "screenshot", label, path: relative(ctx.runDir, abs), note, pageUrl: undefined }); + return `Evidence ${id} registered (${relative(ctx.runDir, abs)}).`; + } + const page = ctx.attachment?.activePage(); + if (!page) { + store.addEvidence({ id, kind: "note", label, note }); + return `Evidence ${id} recorded as a note (no browser attachment available for a screenshot).`; + } + const file = join(store.evidenceDir, `${id}-${safe}.png`); + await page.screenshot({ path: file, fullPage: false }); + store.addEvidence({ id, kind: "screenshot", label, path: relative(ctx.runDir, file), note, pageUrl: page.url() }); + return `Evidence ${id} captured: ${relative(ctx.runDir, file)} (${page.url()}).`; + }, + }); + + const getConsole = def({ + name: "get_console", + description: "Return browser console messages and uncaught page errors captured since the run started (Playwright driver). Use after each flow to check for JavaScript errors.", + readOnly: true, + schema: { + level: z.enum(["error", "warning", "all"]).default("error"), + contains: z.string().optional().describe("Only messages containing this text"), + sinceSeq: z.number().int().optional().describe("Only entries after this sequence number (from a previous call)"), + limit: z.number().int().min(1).max(200).default(50), + }, + handler: async (args) => { + if (!ctx.attachment) return "Console capture is not available in this driver mode. Use the browser's own console tool instead."; + const entries = ctx.attachment.capture.queryConsole(args); + if (entries.length === 0) return `No console entries (level=${args.level}). Last seq: ${ctx.attachment.capture.lastSeq}.`; + return [ + `${entries.length} entries (level=${args.level}, last seq ${ctx.attachment.capture.lastSeq}):`, + ...entries.map((e) => `#${e.seq} [${e.level}] ${e.text}${e.location ? ` (${e.location})` : ""} @ ${e.pageUrl}`), + ].join("\n"); + }, + }); + + const getNetwork = def({ + name: "get_network", + description: + "Return network requests captured since the run started (Playwright driver): method, URL, status, timing, and truncated bodies for API calls. Use it to verify that the right requests were sent and to spot 4xx/5xx responses.", + readOnly: true, + schema: { + urlContains: z.string().optional().describe("Only requests whose URL contains this text"), + onlyErrors: z.boolean().default(false).describe("Only failed requests and HTTP status >= 400"), + onlyApi: z.boolean().default(true).describe("Only document/XHR/fetch requests (skip assets)"), + sinceSeq: z.number().int().optional(), + limit: z.number().int().min(1).max(200).default(40), + includeBodies: z.boolean().default(false), + }, + handler: async (args) => { + if (!ctx.attachment) return "Network capture is not available in this driver mode. Use the browser's own network tool instead."; + const entries = ctx.attachment.capture.queryNetwork(args); + if (entries.length === 0) return `No matching requests. Last seq: ${ctx.attachment.capture.lastSeq}.`; + return [ + `${entries.length} requests (last seq ${ctx.attachment.capture.lastSeq}):`, + ...entries.map((e) => { + const status = e.failed ? `FAILED ${e.failed}` : (e.status ?? "pending"); + let line = `#${e.seq} ${e.method} ${e.url} → ${status}${e.durationMs !== undefined ? ` ${e.durationMs}ms` : ""} [${e.resourceType}]`; + if (args.includeBodies) { + if (e.requestBody) line += `\n request: ${e.requestBody}`; + if (e.responseBody) line += `\n response: ${e.responseBody}`; + } + return line; + }), + ].join("\n"); + }, + }); + + const reportFinding = def({ + name: "report_finding", + description: + "Record a finding (bug, regression, UX/UI issue, console or network error, suggestion). Every finding you intend to mention in the report must go through this tool so it is stored with severity, steps and evidence.", + schema: { + category: FindingCategorySchema, + severity: SeveritySchema.describe("blocker: feature unusable or data loss; major: AC not met or clear bug; minor: wrong but workable; suggestion: improvement"), + title: z.string().max(140), + description: z.string(), + steps: z.array(z.string()).default([]).describe("Steps to reproduce"), + expected: z.string().optional(), + actual: z.string().optional(), + url: z.string().optional(), + acceptanceCriterion: z.string().optional().describe("The AC this finding relates to, if any"), + evidenceIds: z.array(z.string()).default([]), + }, + handler: async (args) => { + const f = store.addFinding(args); + return `Finding ${f.id} recorded ([${f.severity}] ${f.title}).`; + }, + }); + + const recordAc = def({ + name: "record_ac_result", + description: "Record the verification result for one acceptance criterion. Call it once per criterion; a later call for the same criterion replaces the earlier one.", + schema: { + criterion: z.string().describe("The criterion text, verbatim or lightly shortened"), + status: AcStatusSchema, + notes: z.string().default("").describe("How you verified it and what you saw"), + evidenceIds: z.array(z.string()).default([]), + }, + handler: async (args) => { + store.recordAcResult(args); + return `Recorded: "${args.criterion}" → ${args.status}.`; + }, + }); + + const note = def({ + name: "note", + description: "Write a working note into the run log (observations, data you created, assumptions). Notes are kept with the report.", + schema: { text: z.string() }, + handler: async ({ text }) => { + store.addNote(text); + return "Noted."; + }, + }); + + const todoAdd = def({ + name: "todo_add", + description: "Add items to the run's todo list (things to test). Build the list first, then work through it with todo_next / todo_done.", + schema: { items: z.array(z.string()).min(1) }, + handler: async ({ items }) => { + const added = store.todoAdd(items); + return `Added ${added.length} items: ${added.map((t) => `#${t.id} ${t.text}`).join("; ")}`; + }, + }); + + const todoNext = def({ + name: "todo_next", + description: "Return the next open todo item, or a summary when the list is done.", + readOnly: true, + schema: {}, + handler: async () => { + const next = store.todoNext(); + if (next) return `#${next.id} ${next.text}`; + const all = store.todos(); + return `No open items. ${all.filter((t) => t.status === "done").length} done, ${all.filter((t) => t.status === "skipped").length} skipped.`; + }, + }); + + const todoDone = def({ + name: "todo_done", + description: "Mark a todo item as done or skipped with a one-line result.", + schema: { + id: z.number().int(), + result: z.string(), + status: z.enum(["done", "skipped"]).default("done"), + }, + handler: async ({ id, result, status }) => { + const item = store.todoDone(id, result, status); + if (!item) return `No todo item #${id}.`; + const next = store.todoNext(); + return `#${id} ${status}. ${next ? `Next: #${next.id} ${next.text}` : "List complete."}`; + }, + }); + + const tools: ClaudioTool[] = [loginAs, captureEvidence, getConsole, getNetwork, reportFinding, recordAc, note, todoAdd, todoNext, todoDone]; + if (ctx.knowledgeDir) tools.push(writeKnowledgeTool(ctx.knowledgeDir)); + if (ctx.human) tools.push(humanHelpTool(ctx.human, ctx.attachment)); + return tools; +} + +/** + * Pauses the run until a person has dealt with something only a person may + * handle: a CAPTCHA or bot check. The agent never solves those itself. + */ +export function humanHelpTool(human: HumanBridge, attachment: BrowserAttachment | null): ClaudioTool { + return def({ + name: "request_human_help", + description: + "Pause and ask a person to take over the browser, for a CAPTCHA, a bot check or a similar challenge meant for humans. Never try to solve such a challenge yourself. Blocks until the person is done (or gives up), then take a fresh snapshot and continue.", + schema: { reason: z.string().describe("What the person should do, e.g. 'reCAPTCHA auf der Login-Seite lösen'") }, + handler: async ({ reason }) => { + const outcome = await human.wait(reason); + if (outcome !== "resolved") { + return outcome === "timeout" + ? "Nobody responded in time. Record what is blocked (not_testable / note) and continue with what you can test without it, or finish." + : "The person cancelled. Record the blocked part and finish what you can."; + } + const still = await detectCaptcha(attachment?.activePage()); + return still + ? `The person is done, but the page still shows a challenge (${still}). Take a snapshot; if it is still there, record it as blocked.` + : "The person is done. Take a fresh snapshot and continue where you left off."; + }, + }); +} + +/** Writes one document of the repository knowledge base (Markdown). */ +export function writeKnowledgeTool(dir: string): ClaudioTool { + const docList = Object.entries(KNOWLEDGE_DOCS) + .map(([name, d]) => `- ${name}: ${d.purpose}`) + .join("\n"); + return def({ + name: "write_knowledge", + description: `Write a document of the repository knowledge base (Markdown). mode "replace" overwrites the document, "append" adds a section at the end (use it to add one screen at a time to app-map). Documents:\n${docList}`, + schema: { + doc: KnowledgeDocNameSchema, + content: z.string().min(1).max(200_000), + mode: z.enum(["replace", "append"]).default("replace"), + }, + handler: async ({ doc, content, mode }) => { + mkdirSync(dir, { recursive: true }); + const file = join(dir, KNOWLEDGE_DOCS[doc as keyof typeof KNOWLEDGE_DOCS].file); + const text = content.trim() + "\n"; + if (mode === "append" && existsSync(file)) appendFileSync(file, "\n" + text); + else writeFileSync(file, text); + const size = readFileSync(file, "utf8").length; + return `${doc} ${mode === "append" ? "extended" : "written"} (${size} characters).`; + }, + }); +} diff --git a/packages/browser/src/tools/stdio-server.ts b/packages/browser/src/tools/stdio-server.ts new file mode 100644 index 0000000..24c501d --- /dev/null +++ b/packages/browser/src/tools/stdio-server.ts @@ -0,0 +1,74 @@ +/** + * Stand-alone MCP server exposing the Claudio tools over stdio. + * Used by the Claude Code CLI live mode (see .mcp.json). Configuration comes + * from environment variables so the same server works for any run: + * + * CLAUDIO_RUN_DIR directory for findings/evidence/notes (default: runs/live-) + * CLAUDIO_INSTANCE_URL base URL of the app under test (default: unset; login_as then reports it) + * CLAUDIO_CONFIG path to claudio.config.json (optional) + * CLAUDIO_DRIVER playwright | chrome (default chrome) + * CLAUDIO_CDP_ENDPOINT attach to a running Chromium for screenshots/capture (optional) + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { chromium } from "playwright"; +import { z } from "zod"; +import { resolve } from "node:path"; +import { loadRepoConfig, newRunId } from "@claudio/core"; +import { CaptureBuffer } from "../capture.js"; +import type { BrowserAttachment } from "../driver.js"; +import { RunStore } from "../run-store.js"; +import { createClaudioTools, type ToolContext } from "./definitions.js"; + +async function main(): Promise { + // Stay up even when unconfigured (Claude Code loads .mcp.json for every session in this repo); + // the tools then explain what is missing instead of the server dying at startup. + const runDir = process.env.CLAUDIO_RUN_DIR ?? resolve("runs", `live-${newRunId("session")}`); + const instanceUrl = process.env.CLAUDIO_INSTANCE_URL ?? ""; + let config = null as ReturnType; + try { + config = loadRepoConfig(process.env.CLAUDIO_CONFIG); + } catch (e) { + console.error(`claudio: could not load config: ${(e as Error).message}`); + } + const driver = process.env.CLAUDIO_DRIVER === "playwright" ? "playwright" : "chrome"; + + let attachment: BrowserAttachment | null = null; + if (process.env.CLAUDIO_CDP_ENDPOINT) { + const browser = await chromium.connectOverCDP(process.env.CLAUDIO_CDP_ENDPOINT); + const context = browser.contexts()[0] ?? (await browser.newContext()); + const capture = new CaptureBuffer(); + capture.attach(context); + attachment = { + browser, + context, + capture, + cdpEndpoint: process.env.CLAUDIO_CDP_ENDPOINT, + activePage: () => context.pages().filter((p) => !p.isClosed()).at(-1), + }; + } + + const ctx: ToolContext = { + runDir, + store: new RunStore(runDir), + attachment, + instanceUrl, + roles: config?.roles ?? {}, + driver, + }; + + const server = new McpServer({ name: "claudio", version: "0.1.0" }); + for (const t of createClaudioTools(ctx)) { + server.registerTool( + t.name, + { description: t.description, inputSchema: z.object(t.schema).shape, annotations: { readOnlyHint: t.readOnly ?? false } }, + async (args: Record) => ({ content: [{ type: "text" as const, text: await t.handler(args as never) }] }), + ); + } + await server.connect(new StdioServerTransport()); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/browser/src/tools/test-tools.ts b/packages/browser/src/tools/test-tools.ts new file mode 100644 index 0000000..bf4d54f --- /dev/null +++ b/packages/browser/src/tools/test-tools.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; +import { DeterminismSchema, TestOutcomeSchema, TestStepSchema, describeStep, type Determinism, type StepResult, type TestOutcome, type TestStep } from "@claudio/core"; +import { detectCaptcha } from "../human.js"; +import { ariaSnapshot, executeStep, testPage, type StepContext } from "../steps.js"; +import { def, type ClaudioTool } from "./definitions.js"; + +export type TestFinish = { complete: boolean; outcome: TestOutcome; determinism: Determinism; summary: string }; + +/** Collects what the agent did in a test run: every attempted step, and the ones that worked (the recording). */ +export class StepRecorder { + readonly results: StepResult[] = []; + readonly recorded: TestStep[] = []; + finish?: TestFinish; + + constructor( + /** Steps replayed before the agent took over (healing); they count as recorded. */ + readonly prefix: TestStep[] = [], + ) {} + + add(step: TestStep, ok: boolean, durationMs: number, error: string | undefined, healed: boolean): StepResult { + const r: StepResult = { index: this.prefix.length + this.results.length, step, ok, error, durationMs, healed }; + this.results.push(r); + if (ok) this.recorded.push(step); + return r; + } + + compiled(): TestStep[] { + return [...this.prefix, ...this.recorded]; + } +} + +/** + * Tools for recording and healing test scripts. All browser actions go through + * test_step, which Claudio executes itself, so what is recorded is exactly what ran. + */ +export function createTestTools(ctx: StepContext, recorder: StepRecorder, opts: { healing: boolean }): ClaudioTool[] { + const testStep = def({ + name: "test_step", + description: + "Execute one test step in the browser and record it. Actions: login(role), goto(path), click/fill/select/check/uncheck/press on a target, and assertions expect_visible, expect_hidden, expect_text, expect_url, expect_no_errors, plus screenshot(label). Targets, most robust first: {role, name} as in the snapshot (e.g. {role:'button', name:'Speichern'}), {label}, {placeholder}, {text}, {testId}, {css}. Use {{run}} in values for unique test data and {{date:+N:DD.MM.YYYY}} for dates relative to today. Only successful steps are recorded.", + schema: { step: TestStepSchema }, + handler: async ({ step }) => { + if (!ctx.attachment.browser.isConnected()) { + return "The browser has crashed or was closed. Stop now: call finish_test with complete false and outcome inconclusive."; + } + const out = await executeStep(step, ctx); + const r = recorder.add(step, out.ok, out.durationMs, out.error, opts.healing); + const captcha = await detectCaptcha(await testPage(ctx.attachment)); + const hint = captcha ? ` A ${captcha} is on the page: call request_human_help; do not try to solve it yourself.` : ""; + if (out.ok) return `#${r.index} ok: ${describeStep(step)} (${out.durationMs} ms).${hint}`; + return `#${r.index} FAILED: ${describeStep(step)}: ${out.error}${hint || (out.assertionFailed ? "" : " Take a snapshot to find the right target, then retry; failed steps are not recorded.")}`; + }, + }); + + const snapshot = def({ + name: "snapshot", + description: "Accessibility tree of the current page (roles and accessible names) plus URL and title. Use it to choose targets for test_step.", + readOnly: true, + schema: {}, + handler: async () => { + const tree = await ariaSnapshot(ctx.attachment); + const captcha = await detectCaptcha(await testPage(ctx.attachment)); + return captcha ? `${tree}\n\nNOTE: a ${captcha} is on the page. Call request_human_help; do not try to solve it yourself.` : tree; + }, + }); + + const finishTest = def({ + name: "finish_test", + description: + "Finish the test run. complete: you performed the whole flow from the description (regardless of whether the expectation held). outcome: your reading of the expectation. determinism: deterministic if the recorded assertions alone decide pass/fail; semi if replaying the steps works but judging the result needs a person or model (e.g. 'list looks correct'); explorative if the flow itself must adapt each time (data-dependent choices).", + schema: { + complete: z.boolean(), + outcome: TestOutcomeSchema, + determinism: DeterminismSchema, + summary: z.string().describe("Two or three sentences: what you did, what you observed, how the expectation was checked"), + }, + handler: async (args) => { + recorder.finish = args; + return "Recorded. End your turn now with a one-paragraph summary."; + }, + }); + + return [testStep, snapshot, finishTest] as ClaudioTool[]; +} diff --git a/packages/browser/test/playwright-driver.integration.test.ts b/packages/browser/test/playwright-driver.integration.test.ts new file mode 100644 index 0000000..0f935af --- /dev/null +++ b/packages/browser/test/playwright-driver.integration.test.ts @@ -0,0 +1,105 @@ +/** + * Integration test for the Playwright driver against a throwaway local app. + * Runs only when CLAUDIO_INTEGRATION=1 (needs a Chromium: bundled or CLAUDIO_CHROMIUM_PATH). + */ +import { createServer, type Server } from "node:http"; +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { PlaywrightDriver } from "../src/playwright-driver.js"; +import { RunStore } from "../src/run-store.js"; +import { createClaudioTools, type ClaudioTool } from "../src/tools/definitions.js"; +import type { DriverSession } from "../src/driver.js"; + +const enabled = process.env.CLAUDIO_INTEGRATION === "1"; + +describe.skipIf(!enabled)("PlaywrightDriver (integration)", () => { + let server: Server; + let instanceUrl: string; + let session: DriverSession; + let runDir: string; + let tools: Record>; + + beforeAll(async () => { + server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://x"); + if (url.pathname === "/login" && req.method === "GET") { + res.setHeader("content-type", "text/html"); + res.end(`
`); + } else if (url.pathname === "/login" && req.method === "POST") { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const p = new URLSearchParams(body); + if (p.get("email") === "u@test" && p.get("password") === "p") { + res.statusCode = 302; + res.setHeader("location", "/dashboard"); + res.end(); + } else { + res.setHeader("content-type", "text/html"); + res.end(`
Falsche Zugangsdaten
`); + } + }); + } else if (url.pathname === "/dashboard") { + res.setHeader("content-type", "text/html"); + res.end(`

Dashboard

`); + } else if (url.pathname === "/api/broken") { + res.statusCode = 500; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: "kaputt" })); + } else { + res.statusCode = 404; + res.end("nope"); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + instanceUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + runDir = mkdtempSync(join(tmpdir(), "claudio-it-")); + session = await new PlaywrightDriver().start({ runDir, instanceUrl, headed: false }); + process.env.IT_USER = "u@test"; + process.env.IT_PASS = "p"; + process.env.IT_BAD = "nope"; + const store = new RunStore(runDir); + tools = Object.fromEntries( + createClaudioTools({ + runDir, + store, + attachment: session.attachment, + instanceUrl, + driver: "playwright", + roles: { + admin: { usernameEnv: "IT_USER", passwordEnv: "IT_PASS", loginPath: "/login", successUrlContains: "/dashboard" }, + wrong: { usernameEnv: "IT_USER", passwordEnv: "IT_BAD", loginPath: "/login" }, + }, + }).map((t) => [t.name, t]), + ); + }, 60_000); + + afterAll(async () => { + await session?.close(); + server?.close(); + }); + + it("logs in with the adaptive heuristic and captures evidence, console and network", async () => { + expect(await tools.login_as!.handler({ role: "admin" })).toMatch(/Logged in as "admin"/); + await new Promise((r) => setTimeout(r, 800)); + expect(await tools.capture_evidence!.handler({ label: "Dashboard" })).toMatch(/Evidence E1 captured/); + expect(await tools.get_console!.handler({ level: "error", limit: 10 })).toMatch(/boom/); + expect(await tools.get_network!.handler({ onlyErrors: true, onlyApi: true, limit: 10, includeBodies: true })).toMatch(/api\/broken → 500/); + }, 60_000); + + it("reports a failed login without leaking credentials", async () => { + const msg = await tools.login_as!.handler({ role: "wrong" }); + expect(msg).toMatch(/failed/); + expect(msg).toMatch(/Falsche Zugangsdaten/); + expect(msg).not.toContain("nope"); + }, 60_000); + + it("writes console, network and trace files on close", async () => { + await session.close(); + expect(existsSync(join(runDir, "console.json"))).toBe(true); + expect(existsSync(join(runDir, "network.json"))).toBe(true); + expect(existsSync(join(runDir, "trace.zip"))).toBe(true); + }, 60_000); +}); diff --git a/packages/browser/test/run-store.test.ts b/packages/browser/test/run-store.test.ts new file mode 100644 index 0000000..7edebac --- /dev/null +++ b/packages/browser/test/run-store.test.ts @@ -0,0 +1,55 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { RunStore } from "../src/run-store.js"; +import { createClaudioTools } from "../src/tools/definitions.js"; + +function tmp(): string { + return mkdtempSync(join(tmpdir(), "claudio-test-")); +} + +describe("RunStore", () => { + it("persists findings, evidence, AC results and todos", () => { + const dir = tmp(); + const s = new RunStore(dir); + s.addFinding({ category: "ux", severity: "minor", title: "t", description: "d", steps: [], evidenceIds: [] }); + s.addEvidence({ id: s.nextEvidenceId(), kind: "note", label: "l" }); + s.recordAcResult({ criterion: "c", status: "passed", notes: "", evidenceIds: [] }); + s.recordAcResult({ criterion: "c", status: "failed", notes: "later", evidenceIds: [] }); + s.todoAdd(["a", "b"]); + expect(s.todoNext()?.text).toBe("a"); + s.todoDone(1, "ok"); + expect(s.todoNext()?.text).toBe("b"); + + const reopened = new RunStore(dir); + expect(reopened.findings()).toHaveLength(1); + expect(reopened.evidence()[0]!.id).toBe("E1"); + expect(reopened.acResults()).toEqual([expect.objectContaining({ criterion: "c", status: "failed" })]); + expect(reopened.addFinding({ category: "ui", severity: "minor", title: "t2", description: "d", steps: [], evidenceIds: [] }).id).toBe("F2"); + }); +}); + +describe("claudio tools without a browser attachment", () => { + it("handles login, evidence and todo flows", async () => { + const dir = tmp(); + const store = new RunStore(dir); + const tools = createClaudioTools({ + runDir: dir, + store, + attachment: null, + instanceUrl: "https://app.test", + roles: { admin: { usernameEnv: "U", passwordEnv: "P", loginPath: "/login" } }, + driver: "chrome", + }); + const byName = Object.fromEntries(tools.map((t) => [t.name, t])); + expect(await byName.login_as!.handler({ role: "nope" })).toMatch(/Unknown role/); + expect(await byName.login_as!.handler({ role: "admin" })).toMatch(/Live mode/); + expect(await byName.capture_evidence!.handler({ label: "x" })).toMatch(/E1 recorded as a note/); + expect(await byName.get_console!.handler({ level: "error", limit: 10 })).toMatch(/not available/); + expect(await byName.todo_add!.handler({ items: ["eins", "zwei"] })).toMatch(/Added 2/); + expect(await byName.todo_done!.handler({ id: 1, result: "r", status: "done" })).toMatch(/Next: #2/); + expect(await byName.report_finding!.handler({ category: "ux", severity: "major", title: "T", description: "D", steps: [], evidenceIds: ["E1"] })).toMatch(/F1 recorded/); + expect(store.findings()[0]!.evidenceIds).toEqual(["E1"]); + }); +}); diff --git a/packages/browser/test/step-recorder.test.ts b/packages/browser/test/step-recorder.test.ts new file mode 100644 index 0000000..d3f356c --- /dev/null +++ b/packages/browser/test/step-recorder.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { StepRecorder } from "../src/tools/test-tools.js"; + +describe("StepRecorder", () => { + it("records only successful steps and continues after a replayed prefix", () => { + const r = new StepRecorder([{ action: "login", role: "dispatcher" }]); + r.add({ action: "click", target: { text: "Alt" } }, false, 10, "not found", true); + r.add({ action: "click", target: { role: "button", name: "Neu" } }, true, 12, undefined, true); + expect(r.results.map((x) => [x.index, x.ok])).toEqual([[1, false], [2, true]]); + expect(r.compiled()).toEqual([ + { action: "login", role: "dispatcher" }, + { action: "click", target: { role: "button", name: "Neu" } }, + ]); + }); +}); diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/browser/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..332e235 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,17 @@ +{ + "name": "@claudio/core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "zod": "^4.6.5" + } +} diff --git a/packages/core/src/analysis.ts b/packages/core/src/analysis.ts new file mode 100644 index 0000000..7158103 --- /dev/null +++ b/packages/core/src/analysis.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import { BudgetTierSchema, EvidenceSchema, FindingSchema, LanguageSchema, RunCostSchema, RunStatusSchema } from "./schemas.js"; + +/** + * The knowledge base Claudio keeps per repository. Each document is Markdown + * so it can be read by people, diffed between commits and handed to agents. + */ +export const KNOWLEDGE_DOCS = { + overview: { + file: "overview.md", + title: "Überblick", + purpose: "What the application is for, who uses it, tech stack, main domain objects, roles and permissions, how login works.", + }, + modules: { + file: "modules.md", + title: "Module", + purpose: "Module map of the code: directories and key files, what each is responsible for, shared components and stores, server endpoints, data access.", + }, + routes: { + file: "routes.md", + title: "Routen", + purpose: "Route index: one entry per URL path with the files that implement it (page, server load/actions, layout, endpoints) and the shared modules it depends on. Used for impact analysis of pull requests.", + }, + "app-map": { + file: "app-map.md", + title: "App-Karte", + purpose: "Screens and flows as observed in the running app: per screen the URL, purpose, which roles see it, main elements and actions, and the expected (correct) behaviour including validation and feedback. This is the reference for regression checks.", + }, + notes: { + file: "notes.md", + title: "Notizen", + purpose: "Practical notes for future runs: test data that exists or must be created, quirks, slow or flaky areas, things that could not be explored and why.", + }, +} as const; + +export const KnowledgeDocNameSchema = z.enum(["overview", "modules", "routes", "app-map", "notes"]); +export type KnowledgeDocName = z.infer; + +export const KnowledgeDocSchema = z.object({ name: KnowledgeDocNameSchema, content: z.string() }); +export type KnowledgeDoc = z.infer; + +/** A knowledge base as handed to a run: the documents plus where they came from. */ +export type KnowledgeBase = { + commitSha: string; + branch: string; + analyzedAt: string; + docs: KnowledgeDoc[]; +}; + +export const AnalysisRequestSchema = z.object({ + repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "repo must be owner/name"), + /** Branch to analyse; the default branch when omitted. */ + branch: z.string().optional(), + instanceUrl: z.string().url(), + roles: z.array(z.string()).default([]), + budget: BudgetTierSchema.default("standard"), + language: LanguageSchema.default("de"), + headed: z.boolean().default(false), + extraInstructions: z.string().optional(), +}); +export type AnalysisRequest = z.infer; + +export const AnalysisReportSchema = z.object({ + runId: z.string(), + status: RunStatusSchema, + request: AnalysisRequestSchema, + commitSha: z.string(), + branch: z.string(), + /** Analysis this one built on, if it was an update. */ + previousCommitSha: z.string().optional(), + summary: z.string(), + docs: z.array(KnowledgeDocSchema), + /** Bugs noticed along the way and the screenshots taken, kept with the report (the run directory is ephemeral). */ + findings: z.array(FindingSchema).default([]), + evidence: z.array(EvidenceSchema).default([]), + cost: RunCostSchema, + sessionId: z.string().optional(), + errors: z.array(z.string()).default([]), + createdAt: z.string(), +}); +export type AnalysisReport = z.infer; diff --git a/packages/core/src/budget.ts b/packages/core/src/budget.ts new file mode 100644 index 0000000..f251618 --- /dev/null +++ b/packages/core/src/budget.ts @@ -0,0 +1,130 @@ +import type { BudgetTier } from "./schemas.js"; + +export type EffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; + +export type BudgetProfile = { + orchestratorModel: string; + workerModel: string; + effort: EffortLevel; + maxTurns: number; + maxBudgetUsd: number; + maxBrowserSteps: number; + /** Delegate AC verification, regression checks and UX review to separate subagents (reviews only). */ + delegateChecks: boolean; +}; + +/** + * Budget tiers map one user-facing option onto every cost lever at once. + * Numbers are starting points for the Phase 0 spike and are meant to be tuned + * from measured runs. + */ +export const BUDGET_TIERS: Record = { + quick: { + orchestratorModel: "claude-sonnet-5-5", + workerModel: "claude-haiku-5-5", + effort: "medium", + maxTurns: 60, + maxBudgetUsd: 2, + maxBrowserSteps: 80, + delegateChecks: false, + }, + standard: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "high", + maxTurns: 150, + maxBudgetUsd: 8, + maxBrowserSteps: 250, + delegateChecks: true, + }, + thorough: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "xhigh", + maxTurns: 400, + maxBudgetUsd: 25, + maxBrowserSteps: 700, + delegateChecks: true, + }, +}; + +/** + * Repository analysis reads the whole code base and walks through every screen, + * so it gets more turns and browser steps than a review of the same tier. + */ +export const ANALYSIS_TIERS: Record = { + quick: { + orchestratorModel: "claude-sonnet-5-5", + workerModel: "claude-haiku-5-5", + effort: "medium", + maxTurns: 120, + maxBudgetUsd: 4, + maxBrowserSteps: 150, + delegateChecks: false, + }, + standard: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "high", + maxTurns: 300, + maxBudgetUsd: 15, + maxBrowserSteps: 400, + delegateChecks: false, + }, + thorough: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "xhigh", + maxTurns: 600, + maxBudgetUsd: 40, + maxBrowserSteps: 900, + delegateChecks: false, + }, +}; + +export function analysisProfile(tier: BudgetTier): BudgetProfile { + return ANALYSIS_TIERS[tier]; +} + +/** + * Test scripts: one flow per run. Only recording, exploring and healing use the + * model; a deterministic replay costs nothing. + */ +export const TEST_TIERS: Record = { + quick: { + orchestratorModel: "claude-sonnet-5-5", + workerModel: "claude-haiku-5-5", + effort: "medium", + maxTurns: 50, + maxBudgetUsd: 1, + maxBrowserSteps: 60, + delegateChecks: false, + }, + standard: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "high", + maxTurns: 90, + maxBudgetUsd: 3, + maxBrowserSteps: 120, + delegateChecks: false, + }, + thorough: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "xhigh", + maxTurns: 160, + maxBudgetUsd: 8, + maxBrowserSteps: 250, + delegateChecks: false, + }, +}; + +export function testProfile(tier: BudgetTier): BudgetProfile { + return TEST_TIERS[tier]; +} + +export function budgetProfile(tier: BudgetTier, modelOverride?: string): BudgetProfile { + const base = BUDGET_TIERS[tier]; + return modelOverride ? { ...base, orchestratorModel: modelOverride } : base; +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000..17cac25 --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,22 @@ +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { RepoConfigSchema, type RepoConfig, type RoleConfig } from "./schemas.js"; + +export const DEFAULT_CONFIG_FILE = "claudio.config.json"; + +export function loadRepoConfig(path?: string): RepoConfig | null { + const file = resolve(path ?? DEFAULT_CONFIG_FILE); + if (!existsSync(file)) return null; + const raw = JSON.parse(readFileSync(file, "utf8")); + return RepoConfigSchema.parse(raw); +} + +export type Credentials = { username: string; password: string }; + +/** Resolves a role's credentials from the process environment. Returns null when either variable is missing. */ +export function resolveCredentials(role: RoleConfig, env: NodeJS.ProcessEnv = process.env): Credentials | null { + const username = env[role.usernameEnv]; + const password = env[role.passwordEnv]; + if (!username || !password) return null; + return { username, password }; +} diff --git a/packages/core/src/ids.ts b/packages/core/src/ids.ts new file mode 100644 index 0000000..f586c90 --- /dev/null +++ b/packages/core/src/ids.ts @@ -0,0 +1,10 @@ +export function newRunId(prefix = "run"): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; + return `${prefix}-${stamp}-${Math.random().toString(36).slice(2, 6)}`; +} + +export function nowIso(): string { + return new Date().toISOString(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..8c7cbf5 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,7 @@ +export * from "./schemas.js"; +export * from "./budget.js"; +export * from "./report.js"; +export * from "./config.js"; +export * from "./ids.js"; +export * from "./analysis.js"; +export * from "./testing.js"; diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts new file mode 100644 index 0000000..a7798c9 --- /dev/null +++ b/packages/core/src/report.ts @@ -0,0 +1,262 @@ +import type { Finding, ReviewReport, Severity, Strictness, Verbosity } from "./schemas.js"; + +export const SEVERITY_RANK: Record = { + blocker: 4, + major: 3, + minor: 2, + suggestion: 1, +}; + +/** Minimum severity that blocks a merge under a strictness level. */ +export const STRICTNESS_THRESHOLD: Record = { + lenient: "blocker", + normal: "major", + strict: "minor", +}; + +export function isBlocking(finding: Finding, strictness: Strictness): boolean { + return SEVERITY_RANK[finding.severity] >= SEVERITY_RANK[STRICTNESS_THRESHOLD[strictness]]; +} + +/** Findings that block a merge. Findings a person dismissed (by id) never block. */ +export function blockingFindings( + report: ReviewReport, + strictness: Strictness = report.request.strictness, + dismissedIds: readonly string[] = [], +): Finding[] { + return sortBySeverity(report.findings.filter((f) => !dismissedIds.includes(f.id) && isBlocking(f, strictness))); +} + +export function sortBySeverity(findings: Finding[]): Finding[] { + return [...findings].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]); +} + +export type Verdict = "approve" | "request_changes" | "inconclusive"; + +export function verdict(report: ReviewReport, strictness: Strictness = report.request.strictness, dismissedIds: readonly string[] = []): Verdict { + if (report.status !== "completed") return "inconclusive"; + const failedAc = report.acceptanceCriteria.some((ac) => ac.status === "failed"); + if (failedAc || blockingFindings(report, strictness, dismissedIds).length > 0) return "request_changes"; + return "approve"; +} + +const L = { + de: { + verdict: { approve: "Keine blockierenden Findings", request_changes: "Änderungen nötig", inconclusive: "Review unvollständig" }, + summary: "Zusammenfassung", + ac: "Acceptance Criteria", + findings: "Findings", + blocking: "Blockierend", + nonBlocking: "Nicht blockierend", + affected: "Betroffene Bereiche", + evidence: "Evidenz", + steps: "Schritte", + expected: "Erwartet", + actual: "Beobachtet", + cost: "Kosten", + status: "Status", + none: "keine", + acStatus: { passed: "erfüllt", failed: "nicht erfüllt", blocked: "blockiert", not_testable: "nicht testbar" }, + severity: { blocker: "Blocker", major: "Major", minor: "Minor", suggestion: "Vorschlag" }, + fullReport: "Vollständiger Report", + errors: "Fehler während des Runs", + dismissed: "Von Menschen verworfene Findings (nicht aufgeführt)", + tests: "Regressionstests", + testsPassed: "bestanden", + testsFailed: "Fehlgeschlagen", + }, + en: { + verdict: { approve: "No blocking findings", request_changes: "Changes requested", inconclusive: "Review incomplete" }, + summary: "Summary", + ac: "Acceptance criteria", + findings: "Findings", + blocking: "Blocking", + nonBlocking: "Non-blocking", + affected: "Affected areas", + evidence: "Evidence", + steps: "Steps", + expected: "Expected", + actual: "Actual", + cost: "Cost", + status: "Status", + none: "none", + acStatus: { passed: "passed", failed: "failed", blocked: "blocked", not_testable: "not testable" }, + severity: { blocker: "Blocker", major: "Major", minor: "Minor", suggestion: "Suggestion" }, + fullReport: "Full report", + errors: "Errors during the run", + dismissed: "Findings dismissed by a person (not listed)", + tests: "Regression tests", + testsPassed: "passed", + testsFailed: "Failed", + }, +} as const; + +type Labels = (typeof L)[keyof typeof L]; + +function labels(report: ReviewReport): Labels { + return L[report.request.language]; +} + +/** Regression tests that ran with the review. */ +export type ReviewTestResult = { name: string; outcome: "passed" | "failed" | "inconclusive" | null }; + +/** How a person adjusted a finished report: strictness and findings they dismissed, plus the review's regression tests. */ +export type RenderOptions = { strictness?: Strictness; dismissedIds?: readonly string[]; tests?: readonly ReviewTestResult[] }; + +/** A failed regression test turns an approval into "changes requested". */ +export function withTests(v: Verdict, tests: readonly ReviewTestResult[] = []): Verdict { + return v === "approve" && tests.some((t) => t.outcome === "failed") ? "request_changes" : v; +} + +export function renderMarkdown(report: ReviewReport, verbosity: Verbosity = report.request.verbosity, opts: RenderOptions = {}): string { + const t = labels(report); + const strictness = opts.strictness ?? report.request.strictness; + const dismissed = opts.dismissedIds ?? []; + const v = verdict(report, strictness, dismissed); + const blocking = blockingFindings(report, strictness, dismissed); + const nonBlocking = sortBySeverity(report.findings.filter((f) => !dismissed.includes(f.id) && !isBlocking(f, strictness))); + const evidenceById = new Map(report.evidence.map((e) => [e.id, e])); + + const out: string[] = []; + out.push(`# Claudio Review: PR #${report.pr.number} ${report.pr.title}`); + out.push(""); + out.push(`**${t.verdict[v]}** (${t.status}: ${report.status}, strictness: ${strictness})`); + out.push(""); + out.push(`## ${t.summary}`); + out.push(""); + out.push(report.summary.trim() || "-"); + out.push(""); + + if (report.acceptanceCriteria.length > 0) { + out.push(`## ${t.ac}`); + out.push(""); + out.push("| # | Criterion | Status | Notes |"); + out.push("|---|---|---|---|"); + report.acceptanceCriteria.forEach((ac, i) => { + const notes = verbosity === "tldr" ? "" : ac.notes.replace(/\n/g, " "); + out.push(`| ${i + 1} | ${ac.criterion} | ${t.acStatus[ac.status]} | ${notes} |`); + }); + out.push(""); + } + + const renderFinding = (f: Finding) => { + out.push(`### [${t.severity[f.severity]}] ${f.title}`); + out.push(""); + out.push(`_${f.category}_${f.url ? ` · ${f.url}` : ""}${f.acceptanceCriterion ? ` · AC: ${f.acceptanceCriterion}` : ""}`); + out.push(""); + out.push(f.description.trim()); + out.push(""); + if (verbosity === "detailed") { + if (f.steps.length > 0) { + out.push(`**${t.steps}**`); + f.steps.forEach((s, i) => out.push(`${i + 1}. ${s}`)); + out.push(""); + } + if (f.expected) out.push(`**${t.expected}:** ${f.expected}`); + if (f.actual) out.push(`**${t.actual}:** ${f.actual}`); + if (f.expected || f.actual) out.push(""); + const ev = f.evidenceIds.map((id) => evidenceById.get(id)).filter(Boolean); + if (ev.length > 0) { + out.push(`**${t.evidence}:** ${ev.map((e) => (e!.path ? `[${e!.label}](${e!.path})` : e!.label)).join(", ")}`); + out.push(""); + } + } + }; + + out.push(`## ${t.findings}`); + out.push(""); + out.push(`### ${t.blocking} (${blocking.length})`); + out.push(""); + if (blocking.length === 0) out.push(`_${t.none}_`), out.push(""); + if (verbosity === "tldr") { + blocking.forEach((f) => out.push(`- [${t.severity[f.severity]}] ${f.title}`)); + out.push(""); + out.push(`${t.nonBlocking}: ${nonBlocking.length}`); + out.push(""); + } else { + blocking.forEach(renderFinding); + out.push(`### ${t.nonBlocking} (${nonBlocking.length})`); + out.push(""); + if (nonBlocking.length === 0) out.push(`_${t.none}_`), out.push(""); + nonBlocking.forEach(renderFinding); + } + + if (dismissed.length > 0) { + out.push(`_${t.dismissed}: ${dismissed.length}_`); + out.push(""); + } + + if (verbosity !== "tldr" && report.affectedAreas.length > 0) { + out.push(`## ${t.affected}`); + out.push(""); + report.affectedAreas.forEach((a) => out.push(`- ${a}`)); + out.push(""); + } + + if (verbosity === "detailed") { + out.push(`## ${t.cost}`); + out.push(""); + out.push(`| USD | Input tokens | Output tokens | Turns | Browser steps | Duration |`); + out.push(`|---|---|---|---|---|---|`); + out.push( + `| ${report.cost.usd.toFixed(2)} | ${report.cost.inputTokens} | ${report.cost.outputTokens} | ${report.cost.turns} | ${report.cost.browserSteps} | ${Math.round(report.cost.durationMs / 1000)}s |`, + ); + out.push(""); + if (report.notes.trim()) { + out.push("## Notes"); + out.push(""); + out.push(report.notes.trim()); + out.push(""); + } + } + + if (report.errors.length > 0) { + out.push(`## ${t.errors}`); + out.push(""); + report.errors.forEach((e) => out.push(`- ${e}`)); + out.push(""); + } + + return out.join("\n"); +} + +/** Short comment for the pull request: verdict, counts, blocking titles, link to the full report. */ +export function renderPrComment(report: ReviewReport, reportUrl?: string, opts: RenderOptions = {}): string { + const t = labels(report); + const strictness = opts.strictness ?? report.request.strictness; + const dismissed = opts.dismissedIds ?? []; + const tests = opts.tests ?? []; + const v = withTests(verdict(report, strictness, dismissed), tests); + const blocking = blockingFindings(report, strictness, dismissed); + const acPassed = report.acceptanceCriteria.filter((a) => a.status === "passed").length; + const lines: string[] = []; + lines.push(`**Claudio Review: ${t.verdict[v]}**`); + lines.push(""); + lines.push(`${t.ac}: ${acPassed}/${report.acceptanceCriteria.length} ${t.acStatus.passed}. ${t.findings}: ${report.findings.length - dismissed.length} (${t.blocking.toLowerCase()}: ${blocking.length}).`); + if (tests.length > 0) { + const passed = tests.filter((x) => x.outcome === "passed").length; + const failed = tests.filter((x) => x.outcome === "failed"); + lines.push(`${t.tests}: ${passed}/${tests.length} ${t.testsPassed}.${failed.length ? ` ${t.testsFailed}: ${failed.map((x) => x.name).join(", ")}.` : ""}`); + } + if (blocking.length > 0) { + lines.push(""); + blocking.slice(0, 5).forEach((f) => lines.push(`- [${t.severity[f.severity]}] ${f.title}`)); + if (blocking.length > 5) lines.push(`- … +${blocking.length - 5}`); + } + if (report.summary.trim()) { + lines.push(""); + lines.push(firstParagraph(report.summary)); + } + if (reportUrl) { + lines.push(""); + lines.push(`${t.fullReport}: ${reportUrl}`); + } + lines.push(""); + lines.push(`run ${report.runId} · ${report.cost.usd.toFixed(2)} USD · strictness ${strictness}`); + return lines.join("\n"); +} + +function firstParagraph(text: string): string { + const p = text.trim().split(/\n\s*\n/)[0] ?? ""; + return p.length > 600 ? p.slice(0, 597) + "..." : p; +} diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts new file mode 100644 index 0000000..5c4af1b --- /dev/null +++ b/packages/core/src/schemas.ts @@ -0,0 +1,177 @@ +import { z } from "zod"; + +export const SeveritySchema = z.enum(["blocker", "major", "minor", "suggestion"]); +export type Severity = z.infer; + +export const FindingCategorySchema = z.enum([ + "acceptance", + "regression", + "ux", + "ui", + "console", + "network", + "security", + "performance", + "other", +]); +export type FindingCategory = z.infer; + +export const BudgetTierSchema = z.enum(["quick", "standard", "thorough"]); +export type BudgetTier = z.infer; + +export const VerbositySchema = z.enum(["tldr", "normal", "detailed"]); +export type Verbosity = z.infer; + +export const StrictnessSchema = z.enum(["lenient", "normal", "strict"]); +export type Strictness = z.infer; + +export const DriverNameSchema = z.enum(["playwright", "chrome"]); +export type DriverName = z.infer; + +export const LanguageSchema = z.enum(["de", "en"]); +export type Language = z.infer; + +export const EvidenceKindSchema = z.enum(["screenshot", "trace", "console", "network", "note"]); + +export const EvidenceSchema = z.object({ + id: z.string(), + kind: EvidenceKindSchema, + label: z.string(), + path: z.string().optional(), + pageUrl: z.string().optional(), + note: z.string().optional(), + createdAt: z.string(), +}); +export type Evidence = z.infer; + +export const FindingSchema = z.object({ + id: z.string(), + category: FindingCategorySchema, + severity: SeveritySchema, + title: z.string(), + description: z.string(), + steps: z.array(z.string()).default([]), + expected: z.string().optional(), + actual: z.string().optional(), + url: z.string().optional(), + acceptanceCriterion: z.string().optional(), + evidenceIds: z.array(z.string()).default([]), + createdAt: z.string(), +}); +export type Finding = z.infer; + +export const AcStatusSchema = z.enum(["passed", "failed", "blocked", "not_testable"]); + +export const AcceptanceCriterionResultSchema = z.object({ + criterion: z.string(), + status: AcStatusSchema, + notes: z.string().default(""), + evidenceIds: z.array(z.string()).default([]), + createdAt: z.string(), +}); +export type AcceptanceCriterionResult = z.infer; + +export const TodoItemSchema = z.object({ + id: z.number(), + text: z.string(), + status: z.enum(["open", "done", "skipped"]), + result: z.string().optional(), +}); +export type TodoItem = z.infer; + +export const ReviewRequestSchema = z.object({ + repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "repo must be owner/name"), + pr: z.number().int().positive(), + instanceUrl: z.string().url(), + roles: z.array(z.string()).default([]), + driver: DriverNameSchema.default("playwright"), + budget: BudgetTierSchema.default("standard"), + verbosity: VerbositySchema.default("normal"), + strictness: StrictnessSchema.default("normal"), + language: LanguageSchema.default("de"), + headed: z.boolean().default(false), + postComment: z.boolean().default(false), + cloneRepo: z.boolean().default(true), + modelOverride: z.string().optional(), + extraInstructions: z.string().optional(), +}); +export type ReviewRequest = z.infer; + +export const RunCostSchema = z.object({ + usd: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + turns: z.number(), + durationMs: z.number(), + browserSteps: z.number(), +}); +export type RunCost = z.infer; + +export const RunStatusSchema = z.enum(["completed", "budget_exceeded", "max_turns", "failed", "aborted"]); +export type RunStatus = z.infer; + +export const ReviewReportSchema = z.object({ + runId: z.string(), + status: RunStatusSchema, + request: ReviewRequestSchema, + pr: z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + headRef: z.string(), + baseRef: z.string(), + author: z.string().optional(), + headSha: z.string().optional(), + }), + issues: z.array(z.object({ number: z.number(), title: z.string(), url: z.string() })), + summary: z.string(), + affectedAreas: z.array(z.string()).default([]), + acceptanceCriteria: z.array(AcceptanceCriterionResultSchema).default([]), + findings: z.array(FindingSchema).default([]), + evidence: z.array(EvidenceSchema).default([]), + notes: z.string().default(""), + cost: RunCostSchema, + sessionId: z.string().optional(), + errors: z.array(z.string()).default([]), + /** URL of the TL;DR comment posted to the pull request, if any. */ + commentUrl: z.string().optional(), + createdAt: z.string(), +}); +export type ReviewReport = z.infer; + +/** One step of a login recipe. `$USER` and `$PASSWORD` are substituted from the role's env vars. */ +export const LoginStepSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("goto"), url: z.string() }), + z.object({ action: z.literal("fill"), selector: z.string(), value: z.string() }), + z.object({ action: z.literal("click"), selector: z.string() }), + z.object({ action: z.literal("press"), key: z.string() }), + z.object({ action: z.literal("waitForUrl"), pattern: z.string() }), + z.object({ action: z.literal("waitForSelector"), selector: z.string() }), +]); +export type LoginStep = z.infer; + +export const RoleConfigSchema = z.object({ + description: z.string().optional(), + usernameEnv: z.string(), + passwordEnv: z.string(), + /** Explicit login recipe. When omitted, the adaptive auto-login heuristic is used. */ + recipe: z.array(LoginStepSchema).optional(), + /** Path of the login page relative to the instance URL, for the auto-login heuristic. */ + loginPath: z.string().default("/login"), + /** URL fragment or path that indicates a successful login (e.g. "/dashboard"). */ + successUrlContains: z.string().optional(), +}); +export type RoleConfig = z.infer; + +/** claudio.config.json: per-target-repo settings. */ +export const RepoConfigSchema = z.object({ + repo: z.string(), + roles: z.record(z.string(), RoleConfigSchema).default({}), + /** Free-form notes for the agent: domain vocabulary, known quirks, areas to be careful with. */ + notes: z.string().optional(), + /** Hosts the browser may visit in addition to the instance host (e.g. an auth provider). */ + extraAllowedHosts: z.array(z.string()).default([]), + /** What people told Claudio when they dismissed findings: things that are intended, not bugs. */ + lessons: z.array(z.string()).default([]), +}); +export type RepoConfig = z.infer; diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts new file mode 100644 index 0000000..58027da --- /dev/null +++ b/packages/core/src/testing.ts @@ -0,0 +1,213 @@ +import { z } from "zod"; +import { BudgetTierSchema, EvidenceSchema, FindingSchema, LanguageSchema, RunCostSchema, RunStatusSchema } from "./schemas.js"; + +/** + * How Claudio finds an element. Role + accessible name first: it survives + * restyling and is what a user sees. CSS is the last resort. + */ +export const TargetSchema = z.union([ + z.object({ role: z.string(), name: z.string().optional(), exact: z.boolean().optional() }), + z.object({ label: z.string(), exact: z.boolean().optional() }), + z.object({ placeholder: z.string() }), + z.object({ text: z.string(), exact: z.boolean().optional() }), + z.object({ testId: z.string() }), + z.object({ css: z.string() }), +]); +export type Target = z.infer; + +/** + * One recorded step of a test script. Values may contain templates: + * {{run}} (short id of the run, for unique test data) and + * {{date:+N:FORMAT}} (today plus N days, FORMAT DD.MM.YYYY or YYYY-MM-DD). + */ +export const TestStepSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("login"), role: z.string() }), + z.object({ action: z.literal("goto"), path: z.string() }), + z.object({ action: z.literal("click"), target: TargetSchema }), + z.object({ action: z.literal("fill"), target: TargetSchema, value: z.string() }), + z.object({ action: z.literal("select"), target: TargetSchema, value: z.string() }), + z.object({ action: z.literal("check"), target: TargetSchema }), + z.object({ action: z.literal("uncheck"), target: TargetSchema }), + z.object({ action: z.literal("press"), key: z.string(), target: TargetSchema.optional() }), + z.object({ action: z.literal("expect_visible"), target: TargetSchema }), + z.object({ action: z.literal("expect_hidden"), target: TargetSchema }), + z.object({ action: z.literal("expect_text"), text: z.string(), target: TargetSchema.optional() }), + z.object({ action: z.literal("expect_url"), contains: z.string() }), + z.object({ action: z.literal("expect_no_errors") }), + z.object({ action: z.literal("screenshot"), label: z.string() }), +]); +export type TestStep = z.infer; + +export const isAssertion = (step: TestStep) => step.action.startsWith("expect_"); + +export const DeterminismSchema = z.enum(["deterministic", "semi", "explorative"]); +export type Determinism = z.infer; + +export const TestOutcomeSchema = z.enum(["passed", "failed", "inconclusive"]); +export type TestOutcome = z.infer; + +export const TestModeSchema = z.enum(["record", "replay", "explore"]); +export type TestMode = z.infer; + +/** A test script as the runner needs it. */ +export const TestScriptSchema = z.object({ + id: z.number(), + name: z.string(), + /** What to do, in plain language. */ + description: z.string(), + /** What must be true at the end, in plain language. */ + expectation: z.string(), + roles: z.array(z.string()).default([]), + determinism: DeterminismSchema.nullable(), + steps: z.array(TestStepSchema).nullable(), + /** Hosts this test may open besides the instance (and the repo's allowed hosts). */ + allowedHosts: z.array(z.string()).default([]), + /** Let the test navigate anywhere. */ + allowAnyHost: z.boolean().default(false), +}); +export type TestScript = z.infer; + +export const TestRunRequestSchema = z.object({ + repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "repo must be owner/name"), + instanceUrl: z.string().url(), + budget: BudgetTierSchema.default("quick"), + language: LanguageSchema.default("de"), + headed: z.boolean().default(false), + /** Force a fresh recording even when steps exist. */ + rerecord: z.boolean().default(false), +}); +export type TestRunRequest = z.infer; + +export const StepResultSchema = z.object({ + index: z.number(), + step: TestStepSchema, + ok: z.boolean(), + error: z.string().optional(), + durationMs: z.number(), + /** Step was added by the agent while healing a broken replay. */ + healed: z.boolean().default(false), +}); +export type StepResult = z.infer; + +export const JudgeVerdictSchema = z.object({ + passed: z.boolean(), + confidence: z.number().min(0).max(1), + deviations: z.array(z.string()), + reasoning: z.string(), +}); +export type JudgeVerdict = z.infer; + +export const TestRunReportSchema = z.object({ + runId: z.string(), + status: RunStatusSchema, + mode: TestModeSchema, + outcome: TestOutcomeSchema, + /** Index of the replayed step that broke and was healed by the agent. */ + healedAt: z.number().optional(), + steps: z.array(StepResultSchema), + /** Steps to store on the script after this run (new recording or healed replay). */ + compiledSteps: z.array(TestStepSchema).optional(), + determinismProposal: DeterminismSchema.optional(), + judge: JudgeVerdictSchema.optional(), + summary: z.string(), + findings: z.array(FindingSchema).default([]), + evidence: z.array(EvidenceSchema).default([]), + finalUrl: z.string().optional(), + cost: RunCostSchema, + errors: z.array(z.string()).default([]), + createdAt: z.string(), +}); +export type TestRunReport = z.infer; + +/** Normalises user input ("https://www.google.com/x", "Google.com") to bare host names. */ +export function parseHostList(input: string): string[] { + return [ + ...new Set( + input + .split(/[\s,;]+/) + .map((h) => h.trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/[/:?#].*$/, "")) + .filter((h) => /^[a-z0-9.-]+$/.test(h) && h.includes(".")), + ), + ]; +} + +/** Whether a host is the instance or one of the allowed hosts (subdomains included). */ +export function hostAllowed(host: string, allowed: string[]): boolean { + const h = host.toLowerCase(); + return allowed.some((a) => h === a.toLowerCase() || h.endsWith(`.${a.toLowerCase()}`)); +} + +// ---------------------------------------------------------------- templates + +export type TemplateContext = { runTag: string; now?: Date }; + +/** Resolves {{run}} and {{date:+N:FORMAT}} in a step value. Unknown templates are left as they are. */ +export function resolveTemplate(text: string, ctx: TemplateContext): string { + const now = ctx.now ?? new Date(); + return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (whole, expr: string) => { + if (expr === "run") return ctx.runTag; + const m = expr.match(/^date(?::([+-]\d+))?(?::(.+))?$/); + if (!m) return whole; + const d = new Date(now); + d.setDate(d.getDate() + Number(m[1] ?? 0)); + const pad = (n: number) => String(n).padStart(2, "0"); + const parts: Record = { YYYY: String(d.getFullYear()), MM: pad(d.getMonth() + 1), DD: pad(d.getDate()) }; + return (m[2] ?? "YYYY-MM-DD").replace(/YYYY|MM|DD/g, (k) => parts[k]!); + }); +} + +/** Resolves templates everywhere in a step: values, texts, and target names. */ +export function resolveStepTemplates(step: TestStep, ctx: TemplateContext): TestStep { + const walk = (v: unknown): unknown => { + if (typeof v === "string") return resolveTemplate(v, ctx); + if (Array.isArray(v)) return v.map(walk); + if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])); + return v; + }; + return walk(step) as TestStep; +} + +// ---------------------------------------------------------------- human-readable steps + +export function describeTarget(t: Target): string { + if ("role" in t) return `${t.role}${t.name ? ` „${t.name}“` : ""}`; + if ("label" in t) return `Feld „${t.label}“`; + if ("placeholder" in t) return `Feld mit Platzhalter „${t.placeholder}“`; + if ("text" in t) return `Text „${t.text}“`; + if ("testId" in t) return `Test-ID ${t.testId}`; + return `CSS ${t.css}`; +} + +/** German one-liner for the UI. */ +export function describeStep(s: TestStep): string { + switch (s.action) { + case "login": + return `Anmelden als ${s.role}`; + case "goto": + return `Öffnen ${s.path}`; + case "click": + return `Klicken auf ${describeTarget(s.target)}`; + case "fill": + return `${describeTarget(s.target)} ausfüllen mit „${s.value}“`; + case "select": + return `In ${describeTarget(s.target)} „${s.value}“ wählen`; + case "check": + return `${describeTarget(s.target)} ankreuzen`; + case "uncheck": + return `${describeTarget(s.target)} abwählen`; + case "press": + return `Taste ${s.key}${s.target ? ` in ${describeTarget(s.target)}` : ""}`; + case "expect_visible": + return `Prüfen: ${describeTarget(s.target)} ist sichtbar`; + case "expect_hidden": + return `Prüfen: ${describeTarget(s.target)} ist nicht sichtbar`; + case "expect_text": + return `Prüfen: ${s.target ? `${describeTarget(s.target)} enthält` : "Seite enthält"} „${s.text}“`; + case "expect_url": + return `Prüfen: Adresse enthält ${s.contains}`; + case "expect_no_errors": + return "Prüfen: keine Fehler in Konsole und Netzwerk"; + case "screenshot": + return `Screenshot „${s.label}“`; + } +} diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts new file mode 100644 index 0000000..30c4479 --- /dev/null +++ b/packages/core/test/config.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { RepoConfigSchema, resolveCredentials, budgetProfile } from "../src/index.js"; + +describe("config", () => { + it("parses roles with defaults", () => { + const cfg = RepoConfigSchema.parse({ repo: "o/r", roles: { admin: { usernameEnv: "U", passwordEnv: "P" } } }); + expect(cfg.roles.admin!.loginPath).toBe("/login"); + expect(cfg.extraAllowedHosts).toEqual([]); + }); + it("resolves credentials only when both vars exist", () => { + const role = RepoConfigSchema.parse({ repo: "o/r", roles: { a: { usernameEnv: "U", passwordEnv: "P" } } }).roles.a!; + expect(resolveCredentials(role, { U: "x" })).toBeNull(); + expect(resolveCredentials(role, { U: "x", P: "y" })).toEqual({ username: "x", password: "y" }); + }); + it("applies a model override to the orchestrator only", () => { + const p = budgetProfile("standard", "claude-fable-5-1"); + expect(p.orchestratorModel).toBe("claude-fable-5-1"); + expect(p.workerModel).toBe("claude-sonnet-5-5"); + }); +}); diff --git a/packages/core/test/report.test.ts b/packages/core/test/report.test.ts new file mode 100644 index 0000000..6c0a33a --- /dev/null +++ b/packages/core/test/report.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { ReviewReportSchema, blockingFindings, renderMarkdown, renderPrComment, verdict, withTests, type ReviewReport } from "../src/index.js"; + +function report(overrides: Partial = {}): ReviewReport { + return ReviewReportSchema.parse({ + runId: "review-pr1-x", + status: "completed", + request: { repo: "o/r", pr: 1, instanceUrl: "https://pr-1.example.test", strictness: "normal", verbosity: "normal", language: "de" }, + pr: { number: 1, title: "Fahrt anlegen", url: "https://github.com/o/r/pull/1", headRef: "f", baseRef: "main" }, + issues: [], + summary: "Alles gut.\n\nAffected areas:\n- Fahrten", + acceptanceCriteria: [{ criterion: "Fahrt speichern", status: "passed", notes: "", evidenceIds: [], createdAt: "t" }], + findings: [ + { id: "F1", category: "ux", severity: "minor", title: "Button zu klein", description: "d", steps: [], evidenceIds: [], createdAt: "t" }, + { id: "F2", category: "acceptance", severity: "major", title: "Speichern schlägt fehl", description: "d", steps: ["a"], evidenceIds: ["E1"], createdAt: "t" }, + { id: "F3", category: "ui", severity: "suggestion", title: "Farbe", description: "d", steps: [], evidenceIds: [], createdAt: "t" }, + ], + evidence: [{ id: "E1", kind: "screenshot", label: "fail", path: "evidence/E1.png", createdAt: "t" }], + cost: { usd: 1.23, inputTokens: 10, outputTokens: 5, turns: 3, durationMs: 4000, browserSteps: 12 }, + createdAt: "t", + ...overrides, + }); +} + +describe("strictness", () => { + it("normal blocks major and above", () => { + expect(blockingFindings(report()).map((f) => f.id)).toEqual(["F2"]); + }); + it("strict also blocks minor", () => { + expect(blockingFindings(report(), "strict").map((f) => f.id)).toEqual(["F2", "F1"]); + }); + it("lenient blocks only blockers", () => { + expect(blockingFindings(report(), "lenient")).toEqual([]); + expect(verdict(report(), "lenient")).toBe("approve"); + expect(verdict(report(), "normal")).toBe("request_changes"); + }); + it("ignores dismissed findings", () => { + expect(blockingFindings(report(), "normal", ["F2"])).toEqual([]); + expect(verdict(report(), "normal", ["F2"])).toBe("approve"); + expect(verdict(report(), "strict", ["F2"])).toBe("request_changes"); + }); + it("is inconclusive when the run did not complete", () => { + expect(verdict(report({ status: "budget_exceeded" }))).toBe("inconclusive"); + }); +}); + +describe("rendering", () => { + it("tldr lists only blocking titles", () => { + const md = renderMarkdown(report(), "tldr"); + expect(md).toContain("Speichern schlägt fehl"); + expect(md).not.toContain("Button zu klein"); + expect(md).not.toContain("Kosten"); + }); + it("detailed includes steps, evidence and cost", () => { + const md = renderMarkdown(report(), "detailed"); + expect(md).toContain("Button zu klein"); + expect(md).toContain("evidence/E1.png"); + expect(md).toContain("## Kosten"); + expect(md).toContain("Fahrten"); + }); + it("PR comment carries verdict and run id", () => { + const c = renderPrComment(report(), "https://claudio.example/runs/1"); + expect(c).toContain("Änderungen nötig"); + expect(c).toContain("review-pr1-x"); + expect(c).toContain("https://claudio.example/runs/1"); + }); +}); + +describe("rendering with human adjustments", () => { + it("leaves dismissed findings out of the Markdown report and the PR comment", () => { + const md = renderMarkdown(report(), "normal", { dismissedIds: ["F2"] }); + expect(md).not.toContain("Speichern schlägt fehl"); + expect(md).toContain("Button zu klein"); + expect(md).toContain("verworfene Findings"); + const comment = renderPrComment(report(), "https://claudio.example.test/reviews/1", { dismissedIds: ["F2"] }); + expect(comment).toContain("Keine blockierenden Findings"); + expect(comment).toContain("https://claudio.example.test/reviews/1"); + }); + it("applies a stricter level chosen afterwards", () => { + expect(renderPrComment(report(), undefined, { strictness: "strict" })).toContain("Button zu klein"); + }); +}); + +describe("regression tests in the verdict", () => { + it("a failed test turns an approval into changes requested and shows in the comment", () => { + expect(withTests("approve", [{ name: "Fahrt anlegen", outcome: "failed" }])).toBe("request_changes"); + expect(withTests("approve", [{ name: "Fahrt anlegen", outcome: "passed" }])).toBe("approve"); + expect(withTests("inconclusive", [{ name: "x", outcome: "failed" }])).toBe("inconclusive"); + const c = renderPrComment(report(), undefined, { dismissedIds: ["F2"], tests: [{ name: "Fahrt anlegen", outcome: "failed" }, { name: "Suche", outcome: "passed" }] }); + expect(c).toContain("Änderungen nötig"); + expect(c).toContain("Regressionstests: 1/2 bestanden. Fehlgeschlagen: Fahrt anlegen."); + }); +}); diff --git a/packages/core/test/testing.test.ts b/packages/core/test/testing.test.ts new file mode 100644 index 0000000..eca327f --- /dev/null +++ b/packages/core/test/testing.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { TestStepSchema, describeStep, hostAllowed, parseHostList, resolveStepTemplates, resolveTemplate } from "../src/index.js"; + +const now = new Date(2026, 9, 12, 10, 0, 0); + +describe("templates", () => { + it("resolves run tag and relative dates", () => { + expect(resolveTemplate("claudio-{{run}}-Fahrt", { runTag: "abc1", now })).toBe("claudio-abc1-Fahrt"); + expect(resolveTemplate("{{date:+1:DD.MM.YYYY}}", { runTag: "x", now })).toBe("13.10.2026"); + expect(resolveTemplate("{{date:-12}}", { runTag: "x", now })).toBe("2026-09-30"); + expect(resolveTemplate("{{ date }}", { runTag: "x", now })).toBe("2026-10-12"); + }); + it("leaves unknown templates alone", () => { + expect(resolveTemplate("{{user}}", { runTag: "x", now })).toBe("{{user}}"); + }); + it("resolves inside targets too", () => { + const step = TestStepSchema.parse({ action: "expect_visible", target: { role: "cell", name: "claudio-{{run}}" } }); + expect(resolveStepTemplates(step, { runTag: "r9", now })).toEqual({ action: "expect_visible", target: { role: "cell", name: "claudio-r9" } }); + }); +}); + +describe("describeStep", () => { + it("reads like an instruction", () => { + expect(describeStep({ action: "click", target: { role: "button", name: "Speichern" } })).toBe("Klicken auf button „Speichern“"); + expect(describeStep({ action: "fill", target: { label: "Datum" }, value: "{{date:+1}}" })).toBe("Feld „Datum“ ausfüllen mit „{{date:+1}}“"); + expect(describeStep({ action: "expect_no_errors" })).toBe("Prüfen: keine Fehler in Konsole und Netzwerk"); + }); +}); + +describe("allowed hosts", () => { + it("normalises user input to host names", () => { + expect(parseHostList("https://www.Google.com/search?q=x, login.example.de; bad host, localhost")).toEqual(["www.google.com", "login.example.de"]); + }); + it("allows subdomains of allowed hosts only", () => { + expect(hostAllowed("www.google.com", ["google.com"])).toBe(true); + expect(hostAllowed("evilgoogle.com", ["google.com"])).toBe(false); + expect(hostAllowed("127.0.0.1", ["127.0.0.1"])).toBe(true); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..8c004bf --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "mysql", + schema: "./src/schema.ts", + out: "./migrations", +}); diff --git a/packages/db/migrations/0000_init.sql b/packages/db/migrations/0000_init.sql new file mode 100644 index 0000000..3dc1a92 --- /dev/null +++ b/packages/db/migrations/0000_init.sql @@ -0,0 +1,99 @@ +CREATE TABLE `findings` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `review_id` bigint unsigned NOT NULL, + `finding_id` varchar(20) NOT NULL, + `data` json NOT NULL, + `status` enum('proposed','confirmed','dismissed') NOT NULL DEFAULT 'proposed', + `comment` text, + `rated_by` bigint unsigned, + `rated_at` datetime, + CONSTRAINT `findings_id` PRIMARY KEY(`id`), + CONSTRAINT `findings_review_finding_idx` UNIQUE(`review_id`,`finding_id`) +); +--> statement-breakpoint +CREATE TABLE `repo_roles` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `name` varchar(60) NOT NULL, + `description` varchar(300), + `username_enc` text NOT NULL, + `password_enc` text NOT NULL, + `login_path` varchar(300) NOT NULL DEFAULT '/login', + `success_url_contains` varchar(300), + `recipe` json, + `created_at` datetime NOT NULL, + CONSTRAINT `repo_roles_id` PRIMARY KEY(`id`), + CONSTRAINT `repo_roles_repo_name_idx` UNIQUE(`repo_id`,`name`) +); +--> statement-breakpoint +CREATE TABLE `repos` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `full_name` varchar(200) NOT NULL, + `is_private` boolean NOT NULL DEFAULT false, + `default_branch` varchar(200), + `notes` text, + `extra_allowed_hosts` json NOT NULL, + `data_confirmed` boolean NOT NULL DEFAULT false, + `default_instance_url` varchar(500), + `created_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `repos_id` PRIMARY KEY(`id`), + CONSTRAINT `repos_full_name_unique` UNIQUE(`full_name`) +); +--> statement-breakpoint +CREATE TABLE `reviews` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `pr_number` int NOT NULL, + `pr_title` varchar(500), + `instance_url` varchar(500) NOT NULL, + `request` json NOT NULL, + `fixture` varchar(60), + `status` enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted') NOT NULL DEFAULT 'queued', + `run_id` varchar(120), + `report` json, + `verdict` varchar(30), + `cost_usd` varchar(20), + `error` text, + `abort_requested` boolean NOT NULL DEFAULT false, + `started_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + `started_at` datetime, + `finished_at` datetime, + CONSTRAINT `reviews_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `run_events` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `review_id` bigint unsigned NOT NULL, + `seq` int NOT NULL, + `line` mediumtext NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `run_events_id` PRIMARY KEY(`id`), + CONSTRAINT `run_events_review_seq_idx` UNIQUE(`review_id`,`seq`) +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` varchar(64) NOT NULL, + `user_id` bigint unsigned NOT NULL, + `expires_at` datetime NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `sessions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `github_id` bigint unsigned NOT NULL, + `login` varchar(100) NOT NULL, + `name` varchar(200), + `avatar_url` varchar(500), + `access_token_enc` text NOT NULL, + `created_at` datetime NOT NULL, + `last_login_at` datetime NOT NULL, + CONSTRAINT `users_id` PRIMARY KEY(`id`), + CONSTRAINT `users_github_id_unique` UNIQUE(`github_id`) +); +--> statement-breakpoint +CREATE INDEX `reviews_repo_idx` ON `reviews` (`repo_id`);--> statement-breakpoint +CREATE INDEX `reviews_status_idx` ON `reviews` (`status`);--> statement-breakpoint +CREATE INDEX `sessions_user_idx` ON `sessions` (`user_id`); \ No newline at end of file diff --git a/packages/db/migrations/0001_analyses.sql b/packages/db/migrations/0001_analyses.sql new file mode 100644 index 0000000..759ae16 --- /dev/null +++ b/packages/db/migrations/0001_analyses.sql @@ -0,0 +1,38 @@ +CREATE TABLE `analyses` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `request` json NOT NULL, + `instance_url` varchar(500) NOT NULL, + `fixture` varchar(60), + `status` enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted') NOT NULL DEFAULT 'queued', + `run_id` varchar(120), + `branch` varchar(200), + `commit_sha` varchar(64), + `report` json, + `cost_usd` varchar(20), + `error` text, + `abort_requested` boolean NOT NULL DEFAULT false, + `started_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + `started_at` datetime, + `finished_at` datetime, + CONSTRAINT `analyses_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `knowledge_docs` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `analysis_id` bigint unsigned NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `name` varchar(40) NOT NULL, + `content` mediumtext NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `knowledge_docs_id` PRIMARY KEY(`id`), + CONSTRAINT `knowledge_docs_analysis_name_idx` UNIQUE(`analysis_id`,`name`) +); +--> statement-breakpoint +ALTER TABLE `run_events` MODIFY COLUMN `review_id` bigint unsigned;--> statement-breakpoint +ALTER TABLE `run_events` ADD `analysis_id` bigint unsigned;--> statement-breakpoint +ALTER TABLE `run_events` ADD CONSTRAINT `run_events_analysis_seq_idx` UNIQUE(`analysis_id`,`seq`);--> statement-breakpoint +CREATE INDEX `analyses_repo_idx` ON `analyses` (`repo_id`);--> statement-breakpoint +CREATE INDEX `analyses_status_idx` ON `analyses` (`status`);--> statement-breakpoint +CREATE INDEX `knowledge_docs_repo_idx` ON `knowledge_docs` (`repo_id`); \ No newline at end of file diff --git a/packages/db/migrations/0002_review_comment.sql b/packages/db/migrations/0002_review_comment.sql new file mode 100644 index 0000000..112fe40 --- /dev/null +++ b/packages/db/migrations/0002_review_comment.sql @@ -0,0 +1 @@ +ALTER TABLE `reviews` ADD `comment_url` varchar(500); \ No newline at end of file diff --git a/packages/db/migrations/0003_tests.sql b/packages/db/migrations/0003_tests.sql new file mode 100644 index 0000000..a82c2d2 --- /dev/null +++ b/packages/db/migrations/0003_tests.sql @@ -0,0 +1,50 @@ +CREATE TABLE `test_runs` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `script_id` bigint unsigned NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `review_id` bigint unsigned, + `batch_id` varchar(40), + `request` json NOT NULL, + `instance_url` varchar(500) NOT NULL, + `status` enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted') NOT NULL DEFAULT 'queued', + `run_id` varchar(120), + `mode` varchar(20), + `outcome` enum('passed','failed','inconclusive'), + `healed` boolean NOT NULL DEFAULT false, + `steps_version` int, + `report` json, + `cost_usd` varchar(20), + `error` text, + `abort_requested` boolean NOT NULL DEFAULT false, + `started_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + `started_at` datetime, + `finished_at` datetime, + CONSTRAINT `test_runs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `test_scripts` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `name` varchar(200) NOT NULL, + `description` text NOT NULL, + `expectation` text NOT NULL, + `roles` json NOT NULL, + `determinism` enum('deterministic','semi','explorative'), + `steps` json, + `steps_version` int NOT NULL DEFAULT 0, + `steps_updated_at` datetime, + `run_in_reviews` boolean NOT NULL DEFAULT false, + `created_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + CONSTRAINT `test_scripts_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `run_events` ADD `test_run_id` bigint unsigned;--> statement-breakpoint +ALTER TABLE `run_events` ADD CONSTRAINT `run_events_test_seq_idx` UNIQUE(`test_run_id`,`seq`);--> statement-breakpoint +CREATE INDEX `test_runs_script_idx` ON `test_runs` (`script_id`);--> statement-breakpoint +CREATE INDEX `test_runs_review_idx` ON `test_runs` (`review_id`);--> statement-breakpoint +CREATE INDEX `test_runs_status_idx` ON `test_runs` (`status`);--> statement-breakpoint +CREATE INDEX `test_runs_batch_idx` ON `test_runs` (`batch_id`);--> statement-breakpoint +CREATE INDEX `test_scripts_repo_idx` ON `test_scripts` (`repo_id`); \ No newline at end of file diff --git a/packages/db/migrations/0004_automation.sql b/packages/db/migrations/0004_automation.sql new file mode 100644 index 0000000..dcac838 --- /dev/null +++ b/packages/db/migrations/0004_automation.sql @@ -0,0 +1,8 @@ +ALTER TABLE `repos` ADD `automation` json;--> statement-breakpoint +ALTER TABLE `repos` ADD `auto_review` json;--> statement-breakpoint +ALTER TABLE `repos` ADD `webhook_id` bigint;--> statement-breakpoint +ALTER TABLE `repos` ADD `webhook_secret_enc` text;--> statement-breakpoint +ALTER TABLE `repos` ADD `automation_user_id` bigint unsigned;--> statement-breakpoint +ALTER TABLE `repos` ADD `lessons` json NOT NULL;--> statement-breakpoint +ALTER TABLE `reviews` ADD `trigger` varchar(20) DEFAULT 'manual' NOT NULL;--> statement-breakpoint +ALTER TABLE `reviews` ADD `head_sha` varchar(64); \ No newline at end of file diff --git a/packages/db/migrations/0005_lessons_default.sql b/packages/db/migrations/0005_lessons_default.sql new file mode 100644 index 0000000..f4cfa6f --- /dev/null +++ b/packages/db/migrations/0005_lessons_default.sql @@ -0,0 +1,2 @@ +-- Rows that existed before the lessons column got JSON null; the app expects a list. +UPDATE `repos` SET `lessons` = JSON_ARRAY() WHERE `lessons` IS NULL OR JSON_TYPE(`lessons`) = 'NULL'; diff --git a/packages/db/migrations/0006_test_hosts.sql b/packages/db/migrations/0006_test_hosts.sql new file mode 100644 index 0000000..1a32769 --- /dev/null +++ b/packages/db/migrations/0006_test_hosts.sql @@ -0,0 +1,2 @@ +ALTER TABLE `test_scripts` ADD `allowed_hosts` json;--> statement-breakpoint +ALTER TABLE `test_scripts` ADD `allow_any_host` boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/0007_human_takeover.sql b/packages/db/migrations/0007_human_takeover.sql new file mode 100644 index 0000000..f82a338 --- /dev/null +++ b/packages/db/migrations/0007_human_takeover.sql @@ -0,0 +1,25 @@ +CREATE TABLE `human_inputs` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `request_id` bigint unsigned NOT NULL, + `input` json NOT NULL, + `user_id` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `human_inputs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `human_requests` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `run_kind` enum('review','analysis','test') NOT NULL, + `run_ref_id` bigint unsigned NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `reason` varchar(500) NOT NULL, + `status` enum('open','resolved','cancelled','timeout') NOT NULL DEFAULT 'open', + `resolved_by` bigint unsigned, + `created_at` datetime NOT NULL, + `resolved_at` datetime, + CONSTRAINT `human_requests_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE INDEX `human_inputs_request_idx` ON `human_inputs` (`request_id`);--> statement-breakpoint +CREATE INDEX `human_requests_run_idx` ON `human_requests` (`run_kind`,`run_ref_id`);--> statement-breakpoint +CREATE INDEX `human_requests_status_idx` ON `human_requests` (`status`); \ No newline at end of file diff --git a/packages/db/migrations/0008_repo_access.sql b/packages/db/migrations/0008_repo_access.sql new file mode 100644 index 0000000..b6b2e4e --- /dev/null +++ b/packages/db/migrations/0008_repo_access.sql @@ -0,0 +1,7 @@ +CREATE TABLE `repo_access` ( + `user_id` bigint unsigned NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `level` enum('none','read','write','admin') NOT NULL, + `checked_at` datetime NOT NULL, + CONSTRAINT `repo_access_user_repo_idx` UNIQUE(`user_id`,`repo_id`) +); diff --git a/packages/db/migrations/0009_refresh_tokens.sql b/packages/db/migrations/0009_refresh_tokens.sql new file mode 100644 index 0000000..4f32cbe --- /dev/null +++ b/packages/db/migrations/0009_refresh_tokens.sql @@ -0,0 +1,2 @@ +ALTER TABLE `users` ADD `refresh_token_enc` text;--> statement-breakpoint +ALTER TABLE `users` ADD `token_expires_at` datetime; \ No newline at end of file diff --git a/packages/db/migrations/0010_anthropic_keys.sql b/packages/db/migrations/0010_anthropic_keys.sql new file mode 100644 index 0000000..8cfb125 --- /dev/null +++ b/packages/db/migrations/0010_anthropic_keys.sql @@ -0,0 +1,4 @@ +ALTER TABLE `repos` ADD `anthropic_key_enc` text;--> statement-breakpoint +ALTER TABLE `repos` ADD `anthropic_key_hint` varchar(12);--> statement-breakpoint +ALTER TABLE `repos` ADD `anthropic_key_set_by` bigint unsigned;--> statement-breakpoint +ALTER TABLE `repos` ADD `anthropic_key_set_at` datetime; \ No newline at end of file diff --git a/packages/db/migrations/0011_run_artifacts.sql b/packages/db/migrations/0011_run_artifacts.sql new file mode 100644 index 0000000..afedf37 --- /dev/null +++ b/packages/db/migrations/0011_run_artifacts.sql @@ -0,0 +1,13 @@ +CREATE TABLE `run_artifacts` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `run_id` varchar(120) NOT NULL, + `path` varchar(300) NOT NULL, + `content_type` varchar(80) NOT NULL, + `size` int NOT NULL, + `data` mediumblob NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `run_artifacts_id` PRIMARY KEY(`id`), + CONSTRAINT `run_artifacts_run_path_idx` UNIQUE(`run_id`,`path`) +); +--> statement-breakpoint +CREATE INDEX `run_artifacts_created_idx` ON `run_artifacts` (`created_at`); \ No newline at end of file diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..875d9a8 --- /dev/null +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,648 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "98d2c0eb-e5b8-4b70-8e5c-e22c9501921e", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..8a443b4 --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,891 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "22e9ff97-02ca-4450-b819-652c1b212131", + "prevId": "98d2c0eb-e5b8-4b70-8e5c-e22c9501921e", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0002_snapshot.json b/packages/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..0df4ef8 --- /dev/null +++ b/packages/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,898 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "82729a48-32af-4e22-9c3d-74ed1f74195a", + "prevId": "22e9ff97-02ca-4450-b819-652c1b212131", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0003_snapshot.json b/packages/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..a5182f7 --- /dev/null +++ b/packages/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1234 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "2c0284bc-a18c-41bf-8a11-b84ac6d3a38c", + "prevId": "82729a48-32af-4e22-9c3d-74ed1f74195a", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0004_snapshot.json b/packages/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..afb28f4 --- /dev/null +++ b/packages/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,1291 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "f427edd4-2bbb-4f46-bcba-efb63e8fc112", + "prevId": "2c0284bc-a18c-41bf-8a11-b84ac6d3a38c", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation": { + "name": "automation", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_review": { + "name": "auto_review", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_secret_enc": { + "name": "webhook_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_user_id": { + "name": "automation_user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lessons": { + "name": "lessons", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "head_sha": { + "name": "head_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0005_snapshot.json b/packages/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..99cb730 --- /dev/null +++ b/packages/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1291 @@ +{ + "id": "02b79c36-1dee-4e6f-b4d1-e10fb21a8e43", + "prevId": "f427edd4-2bbb-4f46-bcba-efb63e8fc112", + "version": "5", + "dialect": "mysql", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation": { + "name": "automation", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_review": { + "name": "auto_review", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_secret_enc": { + "name": "webhook_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_user_id": { + "name": "automation_user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lessons": { + "name": "lessons", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "head_sha": { + "name": "head_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0006_snapshot.json b/packages/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..344e25c --- /dev/null +++ b/packages/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,1306 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "3e950d5a-e895-4eaf-97f8-7215083440d9", + "prevId": "02b79c36-1dee-4e6f-b4d1-e10fb21a8e43", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation": { + "name": "automation", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_review": { + "name": "auto_review", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_secret_enc": { + "name": "webhook_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_user_id": { + "name": "automation_user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lessons": { + "name": "lessons", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "head_sha": { + "name": "head_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allowed_hosts": { + "name": "allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_any_host": { + "name": "allow_any_host", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0007_snapshot.json b/packages/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..a9ffc99 --- /dev/null +++ b/packages/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,1463 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "737c84dc-3d69-4d92-93e3-c11338da2987", + "prevId": "3e950d5a-e895-4eaf-97f8-7215083440d9", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_inputs": { + "name": "human_inputs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "request_id": { + "name": "request_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "human_inputs_request_idx": { + "name": "human_inputs_request_idx", + "columns": [ + "request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_inputs_id": { + "name": "human_inputs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_requests": { + "name": "human_requests", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "run_kind": { + "name": "run_kind", + "type": "enum('review','analysis','test')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_ref_id": { + "name": "run_ref_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('open','resolved','cancelled','timeout')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "human_requests_run_idx": { + "name": "human_requests_run_idx", + "columns": [ + "run_kind", + "run_ref_id" + ], + "isUnique": false + }, + "human_requests_status_idx": { + "name": "human_requests_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_requests_id": { + "name": "human_requests_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation": { + "name": "automation", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_review": { + "name": "auto_review", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_secret_enc": { + "name": "webhook_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_user_id": { + "name": "automation_user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lessons": { + "name": "lessons", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "head_sha": { + "name": "head_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allowed_hosts": { + "name": "allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_any_host": { + "name": "allow_any_host", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0008_snapshot.json b/packages/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..29304d2 --- /dev/null +++ b/packages/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,1510 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "ade54506-64c1-4428-9d23-8592a207aa48", + "prevId": "737c84dc-3d69-4d92-93e3-c11338da2987", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_inputs": { + "name": "human_inputs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "request_id": { + "name": "request_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "human_inputs_request_idx": { + "name": "human_inputs_request_idx", + "columns": [ + "request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_inputs_id": { + "name": "human_inputs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_requests": { + "name": "human_requests", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "run_kind": { + "name": "run_kind", + "type": "enum('review','analysis','test')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_ref_id": { + "name": "run_ref_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('open','resolved','cancelled','timeout')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "human_requests_run_idx": { + "name": "human_requests_run_idx", + "columns": [ + "run_kind", + "run_ref_id" + ], + "isUnique": false + }, + "human_requests_status_idx": { + "name": "human_requests_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_requests_id": { + "name": "human_requests_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_access": { + "name": "repo_access", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "enum('none','read','write','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_access_user_repo_idx": { + "name": "repo_access_user_repo_idx", + "columns": [ + "user_id", + "repo_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation": { + "name": "automation", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_review": { + "name": "auto_review", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_secret_enc": { + "name": "webhook_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_user_id": { + "name": "automation_user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lessons": { + "name": "lessons", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "head_sha": { + "name": "head_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allowed_hosts": { + "name": "allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_any_host": { + "name": "allow_any_host", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0009_snapshot.json b/packages/db/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..f7a22d4 --- /dev/null +++ b/packages/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,1524 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "4970f439-b518-4bc8-a1ef-26bf4796c17a", + "prevId": "ade54506-64c1-4428-9d23-8592a207aa48", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_inputs": { + "name": "human_inputs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "request_id": { + "name": "request_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "human_inputs_request_idx": { + "name": "human_inputs_request_idx", + "columns": [ + "request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_inputs_id": { + "name": "human_inputs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_requests": { + "name": "human_requests", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "run_kind": { + "name": "run_kind", + "type": "enum('review','analysis','test')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_ref_id": { + "name": "run_ref_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('open','resolved','cancelled','timeout')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "human_requests_run_idx": { + "name": "human_requests_run_idx", + "columns": [ + "run_kind", + "run_ref_id" + ], + "isUnique": false + }, + "human_requests_status_idx": { + "name": "human_requests_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_requests_id": { + "name": "human_requests_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_access": { + "name": "repo_access", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "enum('none','read','write','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_access_user_repo_idx": { + "name": "repo_access_user_repo_idx", + "columns": [ + "user_id", + "repo_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation": { + "name": "automation", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_review": { + "name": "auto_review", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_secret_enc": { + "name": "webhook_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_user_id": { + "name": "automation_user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lessons": { + "name": "lessons", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "head_sha": { + "name": "head_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allowed_hosts": { + "name": "allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_any_host": { + "name": "allow_any_host", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0010_snapshot.json b/packages/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..7c81553 --- /dev/null +++ b/packages/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,1552 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "0c2c99f2-afd8-49c4-9a15-fa79473ada59", + "prevId": "4970f439-b518-4bc8-a1ef-26bf4796c17a", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_inputs": { + "name": "human_inputs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "request_id": { + "name": "request_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "human_inputs_request_idx": { + "name": "human_inputs_request_idx", + "columns": [ + "request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_inputs_id": { + "name": "human_inputs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_requests": { + "name": "human_requests", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "run_kind": { + "name": "run_kind", + "type": "enum('review','analysis','test')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_ref_id": { + "name": "run_ref_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('open','resolved','cancelled','timeout')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "human_requests_run_idx": { + "name": "human_requests_run_idx", + "columns": [ + "run_kind", + "run_ref_id" + ], + "isUnique": false + }, + "human_requests_status_idx": { + "name": "human_requests_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_requests_id": { + "name": "human_requests_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_access": { + "name": "repo_access", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "enum('none','read','write','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_access_user_repo_idx": { + "name": "repo_access_user_repo_idx", + "columns": [ + "user_id", + "repo_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation": { + "name": "automation", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_review": { + "name": "auto_review", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_secret_enc": { + "name": "webhook_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_user_id": { + "name": "automation_user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_key_enc": { + "name": "anthropic_key_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_key_hint": { + "name": "anthropic_key_hint", + "type": "varchar(12)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_key_set_by": { + "name": "anthropic_key_set_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_key_set_at": { + "name": "anthropic_key_set_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lessons": { + "name": "lessons", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "head_sha": { + "name": "head_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allowed_hosts": { + "name": "allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_any_host": { + "name": "allow_any_host", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0011_snapshot.json b/packages/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..336b1e9 --- /dev/null +++ b/packages/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,1634 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "51ea0463-2c68-4246-a956-b9259cf1bd30", + "prevId": "0c2c99f2-afd8-49c4-9a15-fa79473ada59", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_inputs": { + "name": "human_inputs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "request_id": { + "name": "request_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "human_inputs_request_idx": { + "name": "human_inputs_request_idx", + "columns": [ + "request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_inputs_id": { + "name": "human_inputs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "human_requests": { + "name": "human_requests", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "run_kind": { + "name": "run_kind", + "type": "enum('review','analysis','test')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_ref_id": { + "name": "run_ref_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('open','resolved','cancelled','timeout')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "human_requests_run_idx": { + "name": "human_requests_run_idx", + "columns": [ + "run_kind", + "run_ref_id" + ], + "isUnique": false + }, + "human_requests_status_idx": { + "name": "human_requests_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "human_requests_id": { + "name": "human_requests_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_access": { + "name": "repo_access", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "enum('none','read','write','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_access_user_repo_idx": { + "name": "repo_access_user_repo_idx", + "columns": [ + "user_id", + "repo_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation": { + "name": "automation", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_review": { + "name": "auto_review", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_secret_enc": { + "name": "webhook_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_user_id": { + "name": "automation_user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_key_enc": { + "name": "anthropic_key_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_key_hint": { + "name": "anthropic_key_hint", + "type": "varchar(12)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_key_set_by": { + "name": "anthropic_key_set_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_key_set_at": { + "name": "anthropic_key_set_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lessons": { + "name": "lessons", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "head_sha": { + "name": "head_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_artifacts": { + "name": "run_artifacts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "mediumblob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_artifacts_run_path_idx": { + "name": "run_artifacts_run_path_idx", + "columns": [ + "run_id", + "path" + ], + "isUnique": true + }, + "run_artifacts_created_idx": { + "name": "run_artifacts_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_artifacts_id": { + "name": "run_artifacts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allowed_hosts": { + "name": "allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_any_host": { + "name": "allow_any_host", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json new file mode 100644 index 0000000..ed5992f --- /dev/null +++ b/packages/db/migrations/meta/_journal.json @@ -0,0 +1,90 @@ +{ + "version": "7", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1791659909171, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1791662748567, + "tag": "0001_analyses", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1791664551504, + "tag": "0002_review_comment", + "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1791798175090, + "tag": "0003_tests", + "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1791799781255, + "tag": "0004_automation", + "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1791799800132, + "tag": "0005_lessons_default", + "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1791801503841, + "tag": "0006_test_hosts", + "breakpoints": true + }, + { + "idx": 7, + "version": "5", + "when": 1791801994996, + "tag": "0007_human_takeover", + "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1791877969252, + "tag": "0008_repo_access", + "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1791878272034, + "tag": "0009_refresh_tokens", + "breakpoints": true + }, + { + "idx": 10, + "version": "5", + "when": 1791878948906, + "tag": "0010_anthropic_keys", + "breakpoints": true + }, + { + "idx": 11, + "version": "5", + "when": 1791895615552, + "tag": "0011_run_artifacts", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..5090ea5 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,25 @@ +{ + "name": "@claudio/db", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "generate": "drizzle-kit generate", + "migrate": "tsx src/migrate.ts" + }, + "dependencies": { + "@claudio/core": "workspace:*", + "drizzle-orm": "^0.45.4", + "mysql2": "^3.24.5", + "@claudio/github": "workspace:*" + }, + "devDependencies": { + "drizzle-kit": "^0.31.11" + } +} diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts new file mode 100644 index 0000000..149bfe0 --- /dev/null +++ b/packages/db/src/client.ts @@ -0,0 +1,27 @@ +import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; +import mysql from "mysql2/promise"; +import * as schema from "./schema.js"; + +export type Db = MySql2Database; + +let cached: { db: Db; pool: mysql.Pool } | undefined; + +export function databaseUrl(): string { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set"); + return url; +} + +/** One pool per process. */ +export function getDb(url: string = databaseUrl()): Db { + if (!cached) { + const pool = mysql.createPool({ uri: url, connectionLimit: 10, timezone: "Z" }); + cached = { pool, db: drizzle(pool, { schema, mode: "default" }) }; + } + return cached.db; +} + +export async function closeDb(): Promise { + await cached?.pool.end(); + cached = undefined; +} diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts new file mode 100644 index 0000000..797816d --- /dev/null +++ b/packages/db/src/crypto.ts @@ -0,0 +1,33 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; + +const VERSION = "v1"; + +/** + * Derives the 32-byte AES key from CLAUDIO_SECRET_KEY. Any string works; a + * random 32+ character value is expected (`openssl rand -base64 32`). + */ +export function secretKey(raw: string | undefined = process.env.CLAUDIO_SECRET_KEY): Buffer { + if (!raw || raw.length < 16) throw new Error("CLAUDIO_SECRET_KEY is not set (min. 16 characters)"); + return createHash("sha256").update(raw).digest(); +} + +/** AES-256-GCM. Output: v1..., all base64url. */ +export function encrypt(plain: string, key: Buffer = secretKey()): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const data = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return [VERSION, iv.toString("base64url"), tag.toString("base64url"), data.toString("base64url")].join("."); +} + +export function decrypt(token: string, key: Buffer = secretKey()): string { + const [version, iv, tag, data] = token.split("."); + if (version !== VERSION || !iv || !tag || data === undefined) throw new Error("unsupported ciphertext"); + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64url")); + decipher.setAuthTag(Buffer.from(tag, "base64url")); + return Buffer.concat([decipher.update(Buffer.from(data, "base64url")), decipher.final()]).toString("utf8"); +} + +export function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..2f4567d --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,6 @@ +export * from "./schema.js"; +export * from "./client.js"; +export * from "./crypto.js"; +export * from "./repo-config.js"; +export * from "./queries.js"; +export * from "./tokens.js"; diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts new file mode 100644 index 0000000..d66b820 --- /dev/null +++ b/packages/db/src/migrate.ts @@ -0,0 +1,14 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { migrate } from "drizzle-orm/mysql2/migrator"; +import { closeDb, getDb } from "./client.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../../.."); +if (existsSync(resolve(root, ".env"))) process.loadEnvFile(resolve(root, ".env")); + +// DATABASE_MIGRATION_URL: optional user with DDL privileges, only for migrations (Kubernetes init container). +await migrate(getDb(process.env.DATABASE_MIGRATION_URL || undefined), { migrationsFolder: resolve(here, "../migrations") }); +await closeDb(); +console.log("migrations applied"); diff --git a/packages/db/src/queries.ts b/packages/db/src/queries.ts new file mode 100644 index 0000000..ddbdc88 --- /dev/null +++ b/packages/db/src/queries.ts @@ -0,0 +1,960 @@ +import { randomBytes } from "node:crypto"; +import { and, asc, desc, eq, gt, inArray, lt, sql } from "drizzle-orm"; +import type { + AnalysisReport, + AnalysisRequest, + Determinism, + KnowledgeBase, + KnowledgeDoc, + LoginStep, + ReviewReport, + ReviewRequest, + TestRunReport, + TestRunRequest, + TestScript, + TestStep, +} from "@claudio/core"; +import type { AutomationSettings } from "@claudio/github"; +import type { Db } from "./client.js"; +import { decrypt, encrypt, sha256 } from "./crypto.js"; +import { + analyses, + findings, + runArtifacts, + repoAccess, + humanInputs, + humanRequests, + knowledgeDocs, + repoRoles, + repos, + reviews, + runEvents, + sessions, + testRuns, + testScripts, + users, + type AccessLevel, + type Analysis, + type AutoReviewDefaults, + type Lesson, + type FindingStatus, + type HumanInputPayload, + type HumanRequest, + type RunKind, + type Repo, + type Review, + type ReviewStatus, + type TestRun, + type TestScriptRow, + type User, +} from "./schema.js"; + +// ---------------------------------------------------------------- users & sessions + +export async function upsertUser( + db: Db, + input: { githubId: number; login: string; name: string | null; avatarUrl: string | null; accessToken: string; refreshToken?: string; expiresAt?: Date }, +): Promise { + const tokens = { + accessTokenEnc: encrypt(input.accessToken), + refreshTokenEnc: input.refreshToken ? encrypt(input.refreshToken) : null, + tokenExpiresAt: input.expiresAt ?? null, + }; + const values = { githubId: input.githubId, login: input.login, name: input.name, avatarUrl: input.avatarUrl, ...tokens, lastLoginAt: new Date() }; + await db + .insert(users) + .values(values) + .onDuplicateKeyUpdate({ set: { login: values.login, name: values.name, avatarUrl: values.avatarUrl, ...tokens, lastLoginAt: values.lastLoginAt } }); + const [user] = await db.select().from(users).where(eq(users.githubId, input.githubId)); + if (!user) throw new Error("user upsert failed"); + return user; +} + +export async function updateUserTokens(db: Db, userId: number, t: { accessToken: string; refreshToken?: string; expiresAt?: Date }): Promise { + await db + .update(users) + .set({ + accessTokenEnc: encrypt(t.accessToken), + ...(t.refreshToken ? { refreshTokenEnc: encrypt(t.refreshToken) } : {}), + tokenExpiresAt: t.expiresAt ?? null, + }) + .where(eq(users.id, userId)); +} + +export function userAccessToken(user: Pick): string { + return decrypt(user.accessTokenEnc); +} + +export async function getUser(db: Db, id: number): Promise { + const [user] = await db.select().from(users).where(eq(users.id, id)); + return user; +} + +/** Creates a session and returns the raw cookie value (only its hash is stored). */ +export async function createSession(db: Db, userId: number, ttlDays = 14): Promise<{ token: string; expiresAt: Date }> { + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + ttlDays * 86_400_000); + await db.insert(sessions).values({ id: sha256(token), userId, expiresAt }); + return { token, expiresAt }; +} + +export async function getSessionUser(db: Db, token: string): Promise { + const [row] = await db + .select({ user: users, expiresAt: sessions.expiresAt }) + .from(sessions) + .innerJoin(users, eq(users.id, sessions.userId)) + .where(eq(sessions.id, sha256(token))); + if (!row) return null; + if (row.expiresAt.getTime() < Date.now()) { + await deleteSession(db, token); + return null; + } + return row.user; +} + +export async function deleteSession(db: Db, token: string): Promise { + await db.delete(sessions).where(eq(sessions.id, sha256(token))); +} + +export async function deleteExpiredSessions(db: Db): Promise { + await db.delete(sessions).where(lt(sessions.expiresAt, new Date())); +} + +// ---------------------------------------------------------------- repos & roles + +export async function listRepos(db: Db): Promise { + return db.select().from(repos).orderBy(asc(repos.fullName)); +} + +export async function getRepo(db: Db, fullName: string): Promise { + const [repo] = await db.select().from(repos).where(eq(repos.fullName, fullName)); + return repo; +} + +export async function getRepoById(db: Db, id: number): Promise { + const [repo] = await db.select().from(repos).where(eq(repos.id, id)); + return repo; +} + +export async function addRepo( + db: Db, + input: { fullName: string; isPrivate: boolean; defaultBranch: string | null; createdBy: number }, +): Promise { + await db.insert(repos).values(input).onDuplicateKeyUpdate({ set: { isPrivate: input.isPrivate, defaultBranch: input.defaultBranch } }); + const repo = await getRepo(db, input.fullName); + if (!repo) throw new Error("repo insert failed"); + return repo; +} + +export async function updateRepoSettings( + db: Db, + id: number, + input: { notes: string | null; extraAllowedHosts: string[]; dataConfirmed: boolean; defaultInstanceUrl: string | null }, +): Promise { + await db.update(repos).set(input).where(eq(repos.id, id)); +} + +export async function updateAutomation(db: Db, id: number, automation: AutomationSettings, autoReview: AutoReviewDefaults): Promise { + await db.update(repos).set({ automation, autoReview }).where(eq(repos.id, id)); +} + +export async function setWebhook(db: Db, id: number, hook: { webhookId: number; secret: string; userId: number } | null): Promise { + await db + .update(repos) + .set(hook ? { webhookId: hook.webhookId, webhookSecretEnc: encrypt(hook.secret), automationUserId: hook.userId } : { webhookId: null, webhookSecretEnc: null }) + .where(eq(repos.id, id)); +} + +export function webhookSecret(repo: Pick): string | null { + return repo.webhookSecretEnc ? decrypt(repo.webhookSecretEnc) : null; +} + +/** Sets (or with null removes) the repository's Anthropic API key. */ +export async function setAnthropicKey(db: Db, repoId: number, key: string | null, userId: number): Promise { + await db + .update(repos) + .set( + key + ? { anthropicKeyEnc: encrypt(key), anthropicKeyHint: key.slice(-4), anthropicKeySetBy: userId, anthropicKeySetAt: new Date() } + : { anthropicKeyEnc: null, anthropicKeyHint: null, anthropicKeySetBy: userId, anthropicKeySetAt: new Date() }, + ) + .where(eq(repos.id, repoId)); +} + +/** The repository's Anthropic API key in clear text, for the worker. Null when none is set. */ +export function repoAnthropicKey(repo: Pick): string | null { + return repo.anthropicKeyEnc ? decrypt(repo.anthropicKeyEnc) : null; +} + +export async function addLesson(db: Db, repoId: number, lesson: Lesson): Promise { + const repo = await getRepoById(db, repoId); + if (!repo) return; + // One lesson per finding: rating the same finding again replaces it. + const rest = (repo.lessons ?? []).filter((l) => !(l.reviewId === lesson.reviewId && l.findingId === lesson.findingId)); + await db.update(repos).set({ lessons: [...rest, lesson] }).where(eq(repos.id, repoId)); +} + +export async function removeLesson(db: Db, repoId: number, match: { reviewId: number; findingId: string }): Promise { + const repo = await getRepoById(db, repoId); + if (!repo) return; + const lessons = (repo.lessons ?? []).filter((l) => !(l.reviewId === match.reviewId && l.findingId === match.findingId)); + await db.update(repos).set({ lessons }).where(eq(repos.id, repoId)); +} + +export async function removeRepo(db: Db, id: number): Promise { + await db.delete(repoRoles).where(eq(repoRoles.repoId, id)); + await db.delete(repos).where(eq(repos.id, id)); +} + +export async function listRoles(db: Db, repoId: number) { + return db.select().from(repoRoles).where(eq(repoRoles.repoId, repoId)).orderBy(asc(repoRoles.name)); +} + +export type RoleInput = { + name: string; + description: string | null; + /** Omit to keep the stored value (password fields are write-only in the UI). */ + username?: string; + password?: string; + loginPath: string; + successUrlContains: string | null; + recipe: LoginStep[] | null; +}; + +export async function saveRole(db: Db, repoId: number, input: RoleInput): Promise { + const [existing] = await db + .select() + .from(repoRoles) + .where(and(eq(repoRoles.repoId, repoId), eq(repoRoles.name, input.name))); + const base = { + description: input.description, + loginPath: input.loginPath, + successUrlContains: input.successUrlContains, + recipe: input.recipe, + }; + if (existing) { + await db + .update(repoRoles) + .set({ + ...base, + ...(input.username ? { usernameEnc: encrypt(input.username) } : {}), + ...(input.password ? { passwordEnc: encrypt(input.password) } : {}), + }) + .where(eq(repoRoles.id, existing.id)); + return; + } + if (!input.username || !input.password) throw new Error("Benutzername und Passwort sind für eine neue Rolle Pflicht."); + await db.insert(repoRoles).values({ + ...base, + repoId, + name: input.name, + usernameEnc: encrypt(input.username), + passwordEnc: encrypt(input.password), + }); +} + +export async function deleteRole(db: Db, repoId: number, name: string): Promise { + await db.delete(repoRoles).where(and(eq(repoRoles.repoId, repoId), eq(repoRoles.name, name))); +} + +/** Stored username in clear text, for display in the settings form. */ +export function roleUsername(role: { usernameEnc: string }): string { + try { + return decrypt(role.usernameEnc); + } catch { + return ""; + } +} + +// ---------------------------------------------------------------- reviews + +export type ReviewListItem = Review & { repoFullName: string; startedByLogin: string }; + +export async function createReview( + db: Db, + input: { + repoId: number; + prNumber: number; + prTitle: string | null; + request: ReviewRequest; + fixture: string | null; + startedBy: number; + trigger?: string; + headSha?: string | null; + }, +): Promise { + const [res] = await db.insert(reviews).values({ + repoId: input.repoId, + prNumber: input.prNumber, + prTitle: input.prTitle, + instanceUrl: input.request.instanceUrl, + request: input.request, + fixture: input.fixture, + startedBy: input.startedBy, + trigger: input.trigger ?? "manual", + headSha: input.headSha ?? null, + }); + return res.insertId; +} + +export async function listReviews(db: Db, opts: { repoId?: number; repoIds?: number[]; limit?: number } = {}): Promise { + if (opts.repoIds && opts.repoIds.length === 0) return []; + const rows = await db + .select({ review: reviews, repoFullName: repos.fullName, startedByLogin: users.login }) + .from(reviews) + .innerJoin(repos, eq(repos.id, reviews.repoId)) + .innerJoin(users, eq(users.id, reviews.startedBy)) + .where(and(opts.repoId ? eq(reviews.repoId, opts.repoId) : undefined, opts.repoIds ? inArray(reviews.repoId, opts.repoIds) : undefined)) + .orderBy(desc(reviews.id)) + .limit(opts.limit ?? 50); + return rows.map((r) => ({ ...r.review, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin })); +} + +export async function getReview(db: Db, id: number): Promise { + const [row] = await db + .select({ review: reviews, repoFullName: repos.fullName, startedByLogin: users.login }) + .from(reviews) + .innerJoin(repos, eq(repos.id, reviews.repoId)) + .innerJoin(users, eq(users.id, reviews.startedBy)) + .where(eq(reviews.id, id)); + return row ? { ...row.review, repoFullName: row.repoFullName, startedByLogin: row.startedByLogin } : undefined; +} + +/** A queued or running review of the same PR (and commit, if given): a new event should not start another. */ +export async function findActiveReview(db: Db, repoId: number, prNumber: number, headSha?: string | null): Promise { + const [row] = await db + .select() + .from(reviews) + .where( + and( + eq(reviews.repoId, repoId), + eq(reviews.prNumber, prNumber), + inArray(reviews.status, ["queued", "running"]), + headSha ? eq(reviews.headSha, headSha) : undefined, + ), + ) + .limit(1); + return row; +} + +export async function requestAbort(db: Db, id: number): Promise { + // A queued review is aborted right away; a running one is stopped by the worker. + await db.update(reviews).set({ status: "aborted", finishedAt: new Date() }).where(and(eq(reviews.id, id), eq(reviews.status, "queued"))); + await db.update(reviews).set({ abortRequested: true }).where(and(eq(reviews.id, id), eq(reviews.status, "running"))); +} + +export async function isAbortRequested(db: Db, id: number): Promise { + const [row] = await db.select({ abort: reviews.abortRequested }).from(reviews).where(eq(reviews.id, id)); + return Boolean(row?.abort); +} + +/** Atomically moves the oldest queued review to running. Safe with several workers. */ +export async function claimNextReview(db: Db): Promise { + return db.transaction(async (tx) => { + const [next] = await tx + .select() + .from(reviews) + .where(eq(reviews.status, "queued")) + .orderBy(asc(reviews.id)) + .limit(1) + .for("update", { skipLocked: true }); + if (!next) return null; + const startedAt = new Date(); + await tx.update(reviews).set({ status: "running", startedAt }).where(eq(reviews.id, next.id)); + return { ...next, status: "running" as const, startedAt }; + }); +} + +export async function setReviewCommentUrl(db: Db, id: number, commentUrl: string): Promise { + await db.update(reviews).set({ commentUrl }).where(eq(reviews.id, id)); +} + +export async function setReviewRunId(db: Db, id: number, runId: string): Promise { + await db.update(reviews).set({ runId }).where(eq(reviews.id, id)); +} + +export async function finishReview( + db: Db, + id: number, + input: { status: ReviewStatus; report: ReviewReport | null; verdict: string | null; error: string | null }, +): Promise { + await db + .update(reviews) + .set({ + status: input.status, + report: input.report, + verdict: input.verdict, + costUsd: input.report ? input.report.cost.usd.toFixed(4) : null, + error: input.error, + finishedAt: new Date(), + ...(input.report ? { prTitle: input.report.pr.title } : {}), + ...(input.report?.commentUrl ? { commentUrl: input.report.commentUrl } : {}), + ...(input.report?.pr.headSha ? { headSha: input.report.pr.headSha } : {}), + }) + .where(eq(reviews.id, id)); + if (input.report && input.report.findings.length > 0) { + await db + .insert(findings) + .values(input.report.findings.map((f) => ({ reviewId: id, findingId: f.id, data: f }))) + .onDuplicateKeyUpdate({ set: { data: sql`values(${findings.data})` } }); + } +} + +/** Reviews and analyses left in "running" by a crashed worker. */ +export async function failStaleRuns(db: Db): Promise { + const set = { status: "failed" as const, error: "Worker wurde während des Runs beendet.", finishedAt: new Date() }; + const [a] = await db.update(reviews).set(set).where(eq(reviews.status, "running")); + const [b] = await db.update(analyses).set(set).where(eq(analyses.status, "running")); + const [c] = await db.update(testRuns).set(set).where(eq(testRuns.status, "running")); + // Nobody is waiting for an answer any more. + await db.update(humanRequests).set({ status: "cancelled", resolvedAt: new Date() }).where(eq(humanRequests.status, "open")); + return a.affectedRows + b.affectedRows + c.affectedRows; +} + +// ---------------------------------------------------------------- live events + +/** Which run a live log line belongs to. */ +export type EventTarget = { reviewId: number } | { analysisId: number } | { testRunId: number }; + +const eventFilter = (t: EventTarget) => + "reviewId" in t ? eq(runEvents.reviewId, t.reviewId) : "analysisId" in t ? eq(runEvents.analysisId, t.analysisId) : eq(runEvents.testRunId, t.testRunId); + +export async function appendEvent(db: Db, target: EventTarget, seq: number, line: string): Promise { + await db.insert(runEvents).values({ ...target, seq, line }); +} + +export async function eventsSince(db: Db, target: EventTarget, afterSeq: number, limit = 500) { + return db + .select({ seq: runEvents.seq, line: runEvents.line, createdAt: runEvents.createdAt }) + .from(runEvents) + .where(and(eventFilter(target), gt(runEvents.seq, afterSeq))) + .orderBy(asc(runEvents.seq)) + .limit(limit); +} + +// ---------------------------------------------------------------- analyses & knowledge + +export type AnalysisListItem = Analysis & { repoFullName: string; startedByLogin: string; docCount: number }; + +export async function createAnalysis( + db: Db, + input: { repoId: number; request: AnalysisRequest; fixture: string | null; startedBy: number }, +): Promise { + const [res] = await db.insert(analyses).values({ + repoId: input.repoId, + request: input.request, + instanceUrl: input.request.instanceUrl, + fixture: input.fixture, + startedBy: input.startedBy, + }); + return res.insertId; +} + +const analysisSelect = { + analysis: analyses, + repoFullName: repos.fullName, + startedByLogin: users.login, + docCount: sql`(select count(*) from ${knowledgeDocs} where ${knowledgeDocs.analysisId} = ${analyses.id})`.mapWith(Number), +}; + +export async function listAnalyses(db: Db, opts: { repoId?: number; repoIds?: number[]; limit?: number; activeOnly?: boolean } = {}): Promise { + if (opts.repoIds && opts.repoIds.length === 0) return []; + const rows = await db + .select(analysisSelect) + .from(analyses) + .innerJoin(repos, eq(repos.id, analyses.repoId)) + .innerJoin(users, eq(users.id, analyses.startedBy)) + .where( + and( + opts.repoId ? eq(analyses.repoId, opts.repoId) : undefined, + opts.repoIds ? inArray(analyses.repoId, opts.repoIds) : undefined, + opts.activeOnly ? inArray(analyses.status, ["queued", "running"]) : undefined, + ), + ) + .orderBy(desc(analyses.id)) + .limit(opts.limit ?? 50); + return rows.map((r) => ({ ...r.analysis, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin, docCount: r.docCount })); +} + +export async function getAnalysis(db: Db, id: number): Promise { + const [r] = await db + .select(analysisSelect) + .from(analyses) + .innerJoin(repos, eq(repos.id, analyses.repoId)) + .innerJoin(users, eq(users.id, analyses.startedBy)) + .where(eq(analyses.id, id)); + return r ? { ...r.analysis, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin, docCount: r.docCount } : undefined; +} + +export async function claimNextAnalysis(db: Db): Promise { + return db.transaction(async (tx) => { + const [next] = await tx + .select() + .from(analyses) + .where(eq(analyses.status, "queued")) + .orderBy(asc(analyses.id)) + .limit(1) + .for("update", { skipLocked: true }); + if (!next) return null; + const startedAt = new Date(); + await tx.update(analyses).set({ status: "running", startedAt }).where(eq(analyses.id, next.id)); + return { ...next, status: "running" as const, startedAt }; + }); +} + +export async function setAnalysisRunId(db: Db, id: number, runId: string): Promise { + await db.update(analyses).set({ runId }).where(eq(analyses.id, id)); +} + +export async function requestAnalysisAbort(db: Db, id: number): Promise { + await db.update(analyses).set({ status: "aborted", finishedAt: new Date() }).where(and(eq(analyses.id, id), eq(analyses.status, "queued"))); + await db.update(analyses).set({ abortRequested: true }).where(and(eq(analyses.id, id), eq(analyses.status, "running"))); +} + +export async function isAnalysisAbortRequested(db: Db, id: number): Promise { + const [row] = await db.select({ abort: analyses.abortRequested }).from(analyses).where(eq(analyses.id, id)); + return Boolean(row?.abort); +} + +export async function finishAnalysis( + db: Db, + id: number, + input: { status: ReviewStatus; report: AnalysisReport | null; error: string | null }, +): Promise { + const analysis = await db.select({ repoId: analyses.repoId }).from(analyses).where(eq(analyses.id, id)); + const repoId = analysis[0]?.repoId; + const { docs, ...report } = input.report ?? { docs: [] as KnowledgeDoc[] }; + await db + .update(analyses) + .set({ + status: input.status, + report: input.report ? (report as Omit) : null, + branch: input.report?.branch ?? null, + commitSha: input.report?.commitSha ?? null, + costUsd: input.report ? input.report.cost.usd.toFixed(4) : null, + error: input.error, + finishedAt: new Date(), + }) + .where(eq(analyses.id, id)); + if (repoId && docs.length > 0) { + await db.insert(knowledgeDocs).values(docs.map((d) => ({ analysisId: id, repoId, name: d.name, content: d.content }))); + } +} + +export type KnowledgeWithSource = KnowledgeBase & { analysisId: number; status: ReviewStatus; startedByLogin: string }; + +/** The repo's current knowledge base: documents of the newest analysis that produced any. */ +export async function latestKnowledge(db: Db, repoId: number): Promise { + const [latest] = await db + .select({ analysis: analyses, startedByLogin: users.login }) + .from(analyses) + .innerJoin(users, eq(users.id, analyses.startedBy)) + .where(and(eq(analyses.repoId, repoId), sql`exists (select 1 from ${knowledgeDocs} where ${knowledgeDocs.analysisId} = ${analyses.id})`)) + .orderBy(desc(analyses.id)) + .limit(1); + if (!latest) return null; + const docs = await knowledgeDocsOf(db, latest.analysis.id); + return { + analysisId: latest.analysis.id, + status: latest.analysis.status, + startedByLogin: latest.startedByLogin, + commitSha: latest.analysis.commitSha ?? "unknown", + branch: latest.analysis.branch ?? "", + analyzedAt: (latest.analysis.finishedAt ?? latest.analysis.createdAt).toISOString(), + docs, + }; +} + +export async function knowledgeDocsOf(db: Db, analysisId: number): Promise { + const order = ["overview", "modules", "routes", "app-map", "notes"]; + const rows = await db.select({ name: knowledgeDocs.name, content: knowledgeDocs.content }).from(knowledgeDocs).where(eq(knowledgeDocs.analysisId, analysisId)); + return rows.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)); +} + +// ---------------------------------------------------------------- test scripts & runs + +export type TestScriptInput = { + name: string; + description: string; + expectation: string; + roles: string[]; + runInReviews: boolean; + allowedHosts: string[]; + allowAnyHost: boolean; +}; + +export async function createTestScript(db: Db, repoId: number, input: TestScriptInput, createdBy: number): Promise { + const [res] = await db.insert(testScripts).values({ ...input, repoId, createdBy }); + return res.insertId; +} + +export async function updateTestScript(db: Db, id: number, input: TestScriptInput & { determinism: Determinism | null }): Promise { + await db.update(testScripts).set({ ...input, updatedAt: new Date() }).where(eq(testScripts.id, id)); +} + +/** Stores a new recording (or healed version) of the steps. */ +export async function saveTestSteps(db: Db, id: number, steps: TestStep[], determinism?: Determinism): Promise { + await db + .update(testScripts) + .set({ + steps, + stepsVersion: sql`${testScripts.stepsVersion} + 1`, + stepsUpdatedAt: new Date(), + ...(determinism ? { determinism } : {}), + }) + .where(eq(testScripts.id, id)); +} + +/** Clears the recording, so the next run records from scratch. */ +export async function resetTestSteps(db: Db, id: number): Promise { + await db.update(testScripts).set({ steps: null, stepsUpdatedAt: new Date() }).where(eq(testScripts.id, id)); +} + +export async function deleteTestScript(db: Db, id: number): Promise { + await db.delete(runEvents).where(inArray(runEvents.testRunId, db.select({ id: testRuns.id }).from(testRuns).where(eq(testRuns.scriptId, id)))); + await db.delete(testRuns).where(eq(testRuns.scriptId, id)); + await db.delete(testScripts).where(eq(testScripts.id, id)); +} + +export type TestScriptListItem = TestScriptRow & { + repoFullName: string; + lastOutcome: string | null; + lastStatus: string | null; + lastRunAt: Date | null; + lastHealed: boolean; +}; + +export async function listTestScripts(db: Db, repoId: number): Promise { + const rows = await db + .select({ script: testScripts, repoFullName: repos.fullName }) + .from(testScripts) + .innerJoin(repos, eq(repos.id, testScripts.repoId)) + .where(eq(testScripts.repoId, repoId)) + .orderBy(asc(testScripts.name)); + const last = await lastRunsByScript(db, rows.map((r) => r.script.id)); + return rows.map((r) => { + const l = last.get(r.script.id); + return { ...r.script, repoFullName: r.repoFullName, lastOutcome: l?.outcome ?? null, lastStatus: l?.status ?? null, lastRunAt: l?.createdAt ?? null, lastHealed: l?.healed ?? false }; + }); +} + +async function lastRunsByScript(db: Db, scriptIds: number[]): Promise> { + const out = new Map(); + if (scriptIds.length === 0) return out; + const rows = await db.select().from(testRuns).where(inArray(testRuns.scriptId, scriptIds)).orderBy(desc(testRuns.id)); + for (const r of rows) if (!out.has(r.scriptId)) out.set(r.scriptId, r); + return out; +} + +export async function getTestScript(db: Db, id: number): Promise<(TestScriptRow & { repoFullName: string }) | undefined> { + const [r] = await db.select({ script: testScripts, repoFullName: repos.fullName }).from(testScripts).innerJoin(repos, eq(repos.id, testScripts.repoId)).where(eq(testScripts.id, id)); + return r ? { ...r.script, repoFullName: r.repoFullName } : undefined; +} + +export function toTestScript(row: TestScriptRow): TestScript { + return { + id: row.id, + name: row.name, + description: row.description, + expectation: row.expectation, + roles: row.roles ?? [], + determinism: row.determinism ?? null, + steps: row.steps ?? null, + allowedHosts: row.allowedHosts ?? [], + allowAnyHost: row.allowAnyHost, + }; +} + +export async function createTestRuns( + db: Db, + runs: { scriptId: number; repoId: number; request: TestRunRequest; reviewId?: number | null; batchId?: string | null; startedBy: number }[], +): Promise { + if (runs.length === 0) return; + await db.insert(testRuns).values( + runs.map((r) => ({ + scriptId: r.scriptId, + repoId: r.repoId, + reviewId: r.reviewId ?? null, + batchId: r.batchId ?? null, + request: r.request, + instanceUrl: r.request.instanceUrl, + startedBy: r.startedBy, + })), + ); +} + +export async function createTestRun( + db: Db, + run: { scriptId: number; repoId: number; request: TestRunRequest; startedBy: number }, +): Promise { + const [res] = await db.insert(testRuns).values({ ...run, instanceUrl: run.request.instanceUrl }); + return res.insertId; +} + +export type TestRunListItem = TestRun & { scriptName: string; repoFullName: string; startedByLogin: string }; + +const testRunSelect = { run: testRuns, scriptName: testScripts.name, repoFullName: repos.fullName, startedByLogin: users.login }; + +export async function listTestRuns( + db: Db, + filter: { scriptId?: number; reviewId?: number; batchId?: string; repoId?: number; activeOnly?: boolean }, + limit = 50, +): Promise { + const rows = await db + .select(testRunSelect) + .from(testRuns) + .innerJoin(testScripts, eq(testScripts.id, testRuns.scriptId)) + .innerJoin(repos, eq(repos.id, testRuns.repoId)) + .innerJoin(users, eq(users.id, testRuns.startedBy)) + .where( + and( + filter.scriptId ? eq(testRuns.scriptId, filter.scriptId) : undefined, + filter.reviewId ? eq(testRuns.reviewId, filter.reviewId) : undefined, + filter.batchId ? eq(testRuns.batchId, filter.batchId) : undefined, + filter.repoId ? eq(testRuns.repoId, filter.repoId) : undefined, + filter.activeOnly ? inArray(testRuns.status, ["queued", "running"]) : undefined, + ), + ) + .orderBy(desc(testRuns.id)) + .limit(limit); + return rows.map((r) => ({ ...r.run, scriptName: r.scriptName, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin })); +} + +export async function getTestRun(db: Db, id: number): Promise { + const [r] = await db + .select(testRunSelect) + .from(testRuns) + .innerJoin(testScripts, eq(testScripts.id, testRuns.scriptId)) + .innerJoin(repos, eq(repos.id, testRuns.repoId)) + .innerJoin(users, eq(users.id, testRuns.startedBy)) + .where(eq(testRuns.id, id)); + return r ? { ...r.run, scriptName: r.scriptName, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin } : undefined; +} + +/** Regression test results per review, for verdicts in lists. */ +export async function testResultsByReview(db: Db, reviewIds: number[]): Promise> { + const out = new Map(); + if (reviewIds.length === 0) return out; + const rows = await db + .select({ reviewId: testRuns.reviewId, name: testScripts.name, outcome: testRuns.outcome, status: testRuns.status }) + .from(testRuns) + .innerJoin(testScripts, eq(testScripts.id, testRuns.scriptId)) + .where(inArray(testRuns.reviewId, reviewIds)); + for (const r of rows) { + if (r.reviewId === null) continue; + out.set(r.reviewId, [...(out.get(r.reviewId) ?? []), { name: r.name, outcome: r.outcome, status: r.status }]); + } + return out; +} + +export async function claimNextTestRun(db: Db): Promise { + return db.transaction(async (tx) => { + const [next] = await tx + .select() + .from(testRuns) + .where(eq(testRuns.status, "queued")) + .orderBy(asc(testRuns.id)) + .limit(1) + .for("update", { skipLocked: true }); + if (!next) return null; + const startedAt = new Date(); + await tx.update(testRuns).set({ status: "running", startedAt }).where(eq(testRuns.id, next.id)); + return { ...next, status: "running" as const, startedAt }; + }); +} + +export async function setTestRunStarted(db: Db, id: number, runId: string, stepsVersion: number): Promise { + await db.update(testRuns).set({ runId, stepsVersion }).where(eq(testRuns.id, id)); +} + +export async function finishTestRun(db: Db, id: number, input: { status: ReviewStatus; report: TestRunReport | null; error: string | null }): Promise { + await db + .update(testRuns) + .set({ + status: input.status, + report: input.report, + mode: input.report?.mode ?? null, + outcome: input.report?.outcome ?? null, + healed: input.report?.healedAt !== undefined && input.report !== null, + costUsd: input.report ? input.report.cost.usd.toFixed(4) : null, + error: input.error, + finishedAt: new Date(), + }) + .where(eq(testRuns.id, id)); +} + +export async function requestTestRunAbort(db: Db, id: number): Promise { + await db.update(testRuns).set({ status: "aborted", finishedAt: new Date() }).where(and(eq(testRuns.id, id), eq(testRuns.status, "queued"))); + await db.update(testRuns).set({ abortRequested: true }).where(and(eq(testRuns.id, id), eq(testRuns.status, "running"))); +} + +export async function isTestRunAbortRequested(db: Db, id: number): Promise { + const [row] = await db.select({ abort: testRuns.abortRequested }).from(testRuns).where(eq(testRuns.id, id)); + return Boolean(row?.abort); +} + +// ---------------------------------------------------------------- human takeover + +export async function createHumanRequest(db: Db, input: { runKind: RunKind; runRefId: number; repoId: number; reason: string }): Promise { + const [res] = await db.insert(humanRequests).values({ ...input, reason: input.reason.slice(0, 500) }); + return res.insertId; +} + +export async function getHumanRequest(db: Db, id: number): Promise { + const [row] = await db.select().from(humanRequests).where(eq(humanRequests.id, id)); + return row; +} + +export async function openHumanRequest(db: Db, runKind: RunKind, runRefId: number): Promise { + const [row] = await db + .select() + .from(humanRequests) + .where(and(eq(humanRequests.runKind, runKind), eq(humanRequests.runRefId, runRefId), eq(humanRequests.status, "open"))) + .orderBy(desc(humanRequests.id)) + .limit(1); + return row; +} + +export type OpenHumanRequest = HumanRequest & { repoFullName: string }; + +export async function listOpenHumanRequests(db: Db): Promise { + const rows = await db + .select({ req: humanRequests, repoFullName: repos.fullName }) + .from(humanRequests) + .innerJoin(repos, eq(repos.id, humanRequests.repoId)) + .where(eq(humanRequests.status, "open")) + .orderBy(asc(humanRequests.id)); + return rows.map((r) => ({ ...r.req, repoFullName: r.repoFullName })); +} + +export async function closeHumanRequest(db: Db, id: number, status: "resolved" | "cancelled" | "timeout", userId: number | null): Promise { + await db + .update(humanRequests) + .set({ status, resolvedBy: userId, resolvedAt: new Date() }) + .where(and(eq(humanRequests.id, id), eq(humanRequests.status, "open"))); +} + +export async function addHumanInput(db: Db, requestId: number, input: HumanInputPayload, userId: number): Promise { + await db.insert(humanInputs).values({ requestId, input, userId }); +} + +export async function humanInputsSince(db: Db, requestId: number, afterId: number) { + return db + .select({ id: humanInputs.id, input: humanInputs.input }) + .from(humanInputs) + .where(and(eq(humanInputs.requestId, requestId), gt(humanInputs.id, afterId))) + .orderBy(asc(humanInputs.id)); +} + +// ---------------------------------------------------------------- findings + +export async function listFindings(db: Db, reviewId: number) { + return db + .select({ finding: findings, ratedByLogin: users.login }) + .from(findings) + .leftJoin(users, eq(users.id, findings.ratedBy)) + .where(eq(findings.reviewId, reviewId)) + .orderBy(asc(findings.id)); +} + +export async function rateFinding( + db: Db, + reviewId: number, + findingId: string, + input: { status: FindingStatus; comment: string | null; userId: number }, +): Promise { + await db + .update(findings) + .set({ status: input.status, comment: input.comment, ratedBy: input.userId, ratedAt: new Date() }) + .where(and(eq(findings.reviewId, reviewId), eq(findings.findingId, findingId))); +} + +export async function dismissedFindingIds(db: Db, reviewIds: number[]): Promise> { + const out = new Map(); + if (reviewIds.length === 0) return out; + const rows = await db + .select({ reviewId: findings.reviewId, findingId: findings.findingId }) + .from(findings) + .where(and(inArray(findings.reviewId, reviewIds), eq(findings.status, "dismissed"))); + for (const r of rows) out.set(r.reviewId, [...(out.get(r.reviewId) ?? []), r.findingId]); + return out; +} + +// ---------------------------------------------------------------- costs + +export type CostRow = { repo: string; month: string; kind: "review" | "analysis" | "test"; runs: number; usd: number }; + +/** + * Model costs per repository, month and kind of run, as reported by the Agent SDK + * (list-price estimates, not an invoice). Runs without a reported cost count as 0. + */ +export async function costSummary(db: Db, since: Date): Promise { + const [rows] = (await db.execute(sql` + SELECT r.full_name AS repo, DATE_FORMAT(x.created_at, '%Y-%m') AS month, x.kind AS kind, + COUNT(*) AS runs, COALESCE(SUM(CAST(x.cost_usd AS DECIMAL(14,4))), 0) AS usd + FROM ( + SELECT repo_id, created_at, cost_usd, 'review' AS kind FROM ${reviews} + UNION ALL SELECT repo_id, created_at, cost_usd, 'analysis' AS kind FROM ${analyses} + UNION ALL SELECT repo_id, created_at, cost_usd, 'test' AS kind FROM ${testRuns} + ) x + JOIN ${repos} r ON r.id = x.repo_id + WHERE x.created_at >= ${since} + GROUP BY r.full_name, month, x.kind + ORDER BY month DESC, r.full_name + `)) as unknown as [{ repo: string; month: string; kind: CostRow["kind"]; runs: number | string; usd: number | string }[]]; + return rows.map((r) => ({ repo: r.repo, month: r.month, kind: r.kind, runs: Number(r.runs), usd: Number(r.usd) })); +} + +// ---------------------------------------------------------------- access + +export async function cachedAccess(db: Db, userId: number): Promise> { + const rows = await db.select().from(repoAccess).where(eq(repoAccess.userId, userId)); + return new Map(rows.map((r) => [r.repoId, { level: r.level, checkedAt: r.checkedAt }])); +} + +export async function storeAccess(db: Db, userId: number, repoId: number, level: AccessLevel): Promise { + const checkedAt = new Date(); + await db.insert(repoAccess).values({ userId, repoId, level, checkedAt }).onDuplicateKeyUpdate({ set: { level, checkedAt } }); +} + +/** Forget cached permissions (on login), so they are fetched fresh. */ +export async function clearAccess(db: Db, userId: number): Promise { + await db.delete(repoAccess).where(eq(repoAccess.userId, userId)); +} + +/** Which repository a run directory belongs to (for serving its files). */ +export async function repoIdOfRun(db: Db, runId: string): Promise { + for (const table of [reviews, analyses, testRuns] as const) { + const [row] = await db.select({ repoId: table.repoId }).from(table).where(eq(table.runId, runId)).limit(1); + if (row) return row.repoId; + } + return null; +} + +// ---------------------------------------------------------------- run artifacts (screenshots) + +export async function saveArtifact(db: Db, a: { runId: string; path: string; contentType: string; data: Buffer }): Promise { + await db + .insert(runArtifacts) + .values({ ...a, size: a.data.length }) + .onDuplicateKeyUpdate({ set: { data: a.data, size: a.data.length, contentType: a.contentType } }); +} + +export async function getArtifact(db: Db, runId: string, path: string) { + const [row] = await db + .select({ data: runArtifacts.data, contentType: runArtifacts.contentType }) + .from(runArtifacts) + .where(and(eq(runArtifacts.runId, runId), eq(runArtifacts.path, path))); + return row; +} + +/** Which of a run's files are still stored (to tell expired screenshots from present ones). */ +export async function artifactPaths(db: Db, runId: string): Promise> { + const rows = await db.select({ path: runArtifacts.path }).from(runArtifacts).where(eq(runArtifacts.runId, runId)); + return new Set(rows.map((r) => r.path)); +} + +export async function deleteArtifactsBefore(db: Db, before: Date): Promise { + const [res] = await db.delete(runArtifacts).where(lt(runArtifacts.createdAt, before)); + return res.affectedRows; +} diff --git a/packages/db/src/repo-config.ts b/packages/db/src/repo-config.ts new file mode 100644 index 0000000..d0635fc --- /dev/null +++ b/packages/db/src/repo-config.ts @@ -0,0 +1,43 @@ +import { RepoConfigSchema, type RepoConfig } from "@claudio/core"; +import { decrypt } from "./crypto.js"; +import type { Repo, RepoRole } from "./schema.js"; + +/** Env var names under which a role's decrypted credentials are handed to the Claudio tools. */ +export function roleEnvNames(roleName: string): { usernameEnv: string; passwordEnv: string } { + const key = roleName.toUpperCase().replace(/[^A-Z0-9]+/g, "_"); + return { usernameEnv: `CLAUDIO_ROLE_${key}_USER`, passwordEnv: `CLAUDIO_ROLE_${key}_PASSWORD` }; +} + +/** + * Builds the runner's RepoConfig from the stored repo and roles, plus the env + * map that carries the decrypted credentials. The env map goes only to the + * Claudio tools (login_as), never to the agent process. + */ +export function toRepoConfig( + repo: Pick & Partial>, + roles: RepoRole[], + decryptFn: (value: string) => string = decrypt, +): { config: RepoConfig; roleEnv: Record } { + const roleEnv: Record = {}; + const configRoles: Record = {}; + for (const role of roles) { + const names = roleEnvNames(role.name); + roleEnv[names.usernameEnv] = decryptFn(role.usernameEnc); + roleEnv[names.passwordEnv] = decryptFn(role.passwordEnc); + configRoles[role.name] = { + description: role.description ?? undefined, + ...names, + loginPath: role.loginPath, + successUrlContains: role.successUrlContains ?? undefined, + recipe: role.recipe ?? undefined, + }; + } + const config = RepoConfigSchema.parse({ + repo: repo.fullName, + notes: repo.notes ?? undefined, + extraAllowedHosts: repo.extraAllowedHosts ?? [], + lessons: (repo.lessons ?? []).map((l) => l.text), + roles: configRoles, + }); + return { config, roleEnv }; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts new file mode 100644 index 0000000..f1ccabe --- /dev/null +++ b/packages/db/src/schema.ts @@ -0,0 +1,370 @@ +import { bigint, boolean, customType, datetime, index, int, json, mediumtext, mysqlEnum, mysqlTable, text, uniqueIndex, varchar } from "drizzle-orm/mysql-core"; + +/** Binary column up to 16 MB (screenshots). */ +const mediumblob = customType<{ data: Buffer; driverData: Buffer }>({ dataType: () => "mediumblob" }); +import type { AutomationSettings } from "@claudio/github"; +import type { + AnalysisReport, + AnalysisRequest, + Finding, + KnowledgeDocName, + LoginStep, + ReviewReport, + ReviewRequest, + TestRunReport, + TestRunRequest, + TestStep, +} from "@claudio/core"; + +const id = () => bigint("id", { mode: "number", unsigned: true }).autoincrement().primaryKey(); +const ref = (name: string) => bigint(name, { mode: "number", unsigned: true }); +const createdAt = () => datetime("created_at", { mode: "date" }).notNull().$defaultFn(() => new Date()); + +export const users = mysqlTable("users", { + id: id(), + githubId: bigint("github_id", { mode: "number", unsigned: true }).notNull().unique(), + login: varchar("login", { length: 100 }).notNull(), + name: varchar("name", { length: 200 }), + avatarUrl: varchar("avatar_url", { length: 500 }), + /** AES-GCM encrypted OAuth access token. */ + accessTokenEnc: text("access_token_enc").notNull(), + /** Set when the OAuth App issues expiring tokens: used to renew the access token. */ + refreshTokenEnc: text("refresh_token_enc"), + tokenExpiresAt: datetime("token_expires_at", { mode: "date" }), + createdAt: createdAt(), + lastLoginAt: datetime("last_login_at", { mode: "date" }).notNull().$defaultFn(() => new Date()), +}); + +export const sessions = mysqlTable( + "sessions", + { + /** SHA-256 of the cookie value, so a leaked table does not hand out sessions. */ + id: varchar("id", { length: 64 }).primaryKey(), + userId: ref("user_id").notNull(), + expiresAt: datetime("expires_at", { mode: "date" }).notNull(), + createdAt: createdAt(), + }, + (t) => [index("sessions_user_idx").on(t.userId)], +); + +export const repos = mysqlTable("repos", { + id: id(), + /** owner/name as on GitHub. */ + fullName: varchar("full_name", { length: 200 }).notNull().unique(), + isPrivate: boolean("is_private").notNull().default(false), + defaultBranch: varchar("default_branch", { length: 200 }), + notes: text("notes"), + extraAllowedHosts: json("extra_allowed_hosts").$type().notNull().$defaultFn(() => []), + /** Confirmation that review instances only hold synthetic data (screenshots go to Anthropic). */ + dataConfirmed: boolean("data_confirmed").notNull().default(false), + defaultInstanceUrl: varchar("default_instance_url", { length: 500 }), + /** Webhook-driven automation (Phase 5). Null until configured. */ + automation: json("automation").$type(), + /** Defaults for reviews started by GitHub events. */ + autoReview: json("auto_review").$type(), + webhookId: bigint("webhook_id", { mode: "number" }), + webhookSecretEnc: text("webhook_secret_enc"), + /** Whose GitHub token automated runs use (the person who installed the webhook). */ + automationUserId: ref("automation_user_id"), + /** The repository's own Anthropic API key (encrypted). Runs of this repo use only this key. */ + anthropicKeyEnc: text("anthropic_key_enc"), + /** Last characters of the key, to recognise it without showing it. */ + anthropicKeyHint: varchar("anthropic_key_hint", { length: 12 }), + anthropicKeySetBy: ref("anthropic_key_set_by"), + anthropicKeySetAt: datetime("anthropic_key_set_at", { mode: "date" }), + /** Dismissed findings with a reason; every later review is told about them. */ + lessons: json("lessons").$type().notNull().$defaultFn(() => []), + createdBy: ref("created_by").notNull(), + createdAt: createdAt(), +}); + +export type AutoReviewDefaults = { + roles: string[]; + budget: "quick" | "standard" | "thorough"; + strictness: "lenient" | "normal" | "strict"; + postComment: boolean; + runTests: boolean; +}; + +export type Lesson = { text: string; reviewId: number; findingId: string; by: string; at: string }; + +export const repoRoles = mysqlTable( + "repo_roles", + { + id: id(), + repoId: ref("repo_id").notNull(), + name: varchar("name", { length: 60 }).notNull(), + description: varchar("description", { length: 300 }), + usernameEnc: text("username_enc").notNull(), + passwordEnc: text("password_enc").notNull(), + loginPath: varchar("login_path", { length: 300 }).notNull().default("/login"), + successUrlContains: varchar("success_url_contains", { length: 300 }), + recipe: json("recipe").$type(), + createdAt: createdAt(), + }, + (t) => [uniqueIndex("repo_roles_repo_name_idx").on(t.repoId, t.name)], +); + +export const REVIEW_STATUSES = ["queued", "running", "completed", "budget_exceeded", "max_turns", "failed", "aborted"] as const; +export type ReviewStatus = (typeof REVIEW_STATUSES)[number]; + +export const reviews = mysqlTable( + "reviews", + { + id: id(), + repoId: ref("repo_id").notNull(), + prNumber: int("pr_number").notNull(), + prTitle: varchar("pr_title", { length: 500 }), + instanceUrl: varchar("instance_url", { length: 500 }).notNull(), + request: json("request").$type().notNull(), + /** Use the demo fixture instead of GitHub (only honoured with CLAUDIO_DEV_FIXTURES=1). */ + fixture: varchar("fixture", { length: 60 }), + /** manual, comment, label or push (automatic). */ + trigger: varchar("trigger", { length: 20 }).notNull().default("manual"), + /** PR head commit the review is about (for the commit status). */ + headSha: varchar("head_sha", { length: 64 }), + status: mysqlEnum("status", REVIEW_STATUSES).notNull().default("queued"), + runId: varchar("run_id", { length: 120 }), + report: json("report").$type(), + verdict: varchar("verdict", { length: 30 }), + costUsd: varchar("cost_usd", { length: 20 }), + error: text("error"), + /** TL;DR comment on the pull request (posted by the run or later from the UI). */ + commentUrl: varchar("comment_url", { length: 500 }), + abortRequested: boolean("abort_requested").notNull().default(false), + startedBy: ref("started_by").notNull(), + createdAt: createdAt(), + startedAt: datetime("started_at", { mode: "date" }), + finishedAt: datetime("finished_at", { mode: "date" }), + }, + (t) => [index("reviews_repo_idx").on(t.repoId), index("reviews_status_idx").on(t.status)], +); + +export const analyses = mysqlTable( + "analyses", + { + id: id(), + repoId: ref("repo_id").notNull(), + request: json("request").$type().notNull(), + instanceUrl: varchar("instance_url", { length: 500 }).notNull(), + /** Use the demo checkout instead of cloning (only honoured with CLAUDIO_DEV_FIXTURES=1). */ + fixture: varchar("fixture", { length: 60 }), + status: mysqlEnum("status", REVIEW_STATUSES).notNull().default("queued"), + runId: varchar("run_id", { length: 120 }), + branch: varchar("branch", { length: 200 }), + commitSha: varchar("commit_sha", { length: 64 }), + /** Report without the documents (those live in knowledge_docs). */ + report: json("report").$type>(), + costUsd: varchar("cost_usd", { length: 20 }), + error: text("error"), + abortRequested: boolean("abort_requested").notNull().default(false), + startedBy: ref("started_by").notNull(), + createdAt: createdAt(), + startedAt: datetime("started_at", { mode: "date" }), + finishedAt: datetime("finished_at", { mode: "date" }), + }, + (t) => [index("analyses_repo_idx").on(t.repoId), index("analyses_status_idx").on(t.status)], +); + +/** Knowledge documents written by an analysis. The latest analysis with documents is the repo's knowledge base. */ +export const knowledgeDocs = mysqlTable( + "knowledge_docs", + { + id: id(), + analysisId: ref("analysis_id").notNull(), + repoId: ref("repo_id").notNull(), + name: varchar("name", { length: 40 }).$type().notNull(), + content: mediumtext("content").notNull(), + createdAt: createdAt(), + }, + (t) => [uniqueIndex("knowledge_docs_analysis_name_idx").on(t.analysisId, t.name), index("knowledge_docs_repo_idx").on(t.repoId)], +); + +export const DETERMINISM = ["deterministic", "semi", "explorative"] as const; + +/** A test workflow: plain-language flow and expectation, plus the recorded steps once it has run. */ +export const testScripts = mysqlTable( + "test_scripts", + { + id: id(), + repoId: ref("repo_id").notNull(), + name: varchar("name", { length: 200 }).notNull(), + description: text("description").notNull(), + expectation: text("expectation").notNull(), + roles: json("roles").$type().notNull().$defaultFn(() => []), + /** Null until the first recording proposes one. */ + determinism: mysqlEnum("determinism", DETERMINISM), + steps: json("steps").$type(), + stepsVersion: int("steps_version").notNull().default(0), + stepsUpdatedAt: datetime("steps_updated_at", { mode: "date" }), + /** Run as a regression test with every PR review of the repo. */ + runInReviews: boolean("run_in_reviews").notNull().default(false), + /** Hosts this test may open besides the instance and the repo's allowed hosts. */ + allowedHosts: json("allowed_hosts").$type(), + allowAnyHost: boolean("allow_any_host").notNull().default(false), + createdBy: ref("created_by").notNull(), + createdAt: createdAt(), + updatedAt: datetime("updated_at", { mode: "date" }).notNull().$defaultFn(() => new Date()), + }, + (t) => [index("test_scripts_repo_idx").on(t.repoId)], +); + +export const TEST_OUTCOMES = ["passed", "failed", "inconclusive"] as const; + +export const testRuns = mysqlTable( + "test_runs", + { + id: id(), + scriptId: ref("script_id").notNull(), + repoId: ref("repo_id").notNull(), + /** Set when the test ran as part of a PR review. */ + reviewId: ref("review_id"), + /** Groups the runs of one suite start. */ + batchId: varchar("batch_id", { length: 40 }), + request: json("request").$type().notNull(), + instanceUrl: varchar("instance_url", { length: 500 }).notNull(), + status: mysqlEnum("status", REVIEW_STATUSES).notNull().default("queued"), + runId: varchar("run_id", { length: 120 }), + mode: varchar("mode", { length: 20 }), + outcome: mysqlEnum("outcome", TEST_OUTCOMES), + healed: boolean("healed").notNull().default(false), + /** Steps version that was replayed (0 = recording). */ + stepsVersion: int("steps_version"), + report: json("report").$type(), + costUsd: varchar("cost_usd", { length: 20 }), + error: text("error"), + abortRequested: boolean("abort_requested").notNull().default(false), + startedBy: ref("started_by").notNull(), + createdAt: createdAt(), + startedAt: datetime("started_at", { mode: "date" }), + finishedAt: datetime("finished_at", { mode: "date" }), + }, + (t) => [ + index("test_runs_script_idx").on(t.scriptId), + index("test_runs_review_idx").on(t.reviewId), + index("test_runs_status_idx").on(t.status), + index("test_runs_batch_idx").on(t.batchId), + ], +); + +/** + * Files of a run that must outlive the pod (screenshots), stored in the database so the + * app keeps no state on disk. Deleted after the retention period (default 14 days). + */ +export const runArtifacts = mysqlTable( + "run_artifacts", + { + id: id(), + runId: varchar("run_id", { length: 120 }).notNull(), + /** Path inside the run directory, as referenced by the report (e.g. evidence/E1-Fahrtenliste.png). */ + path: varchar("path", { length: 300 }).notNull(), + contentType: varchar("content_type", { length: 80 }).notNull(), + size: int("size").notNull(), + data: mediumblob("data").notNull(), + createdAt: createdAt(), + }, + (t) => [uniqueIndex("run_artifacts_run_path_idx").on(t.runId, t.path), index("run_artifacts_created_idx").on(t.createdAt)], +); + +export const ACCESS_LEVELS = ["none", "read", "write", "admin"] as const; +export type AccessLevel = (typeof ACCESS_LEVELS)[number]; + +/** A user's GitHub permission on a repository, cached for a few minutes. */ +export const repoAccess = mysqlTable( + "repo_access", + { + userId: ref("user_id").notNull(), + repoId: ref("repo_id").notNull(), + level: mysqlEnum("level", ACCESS_LEVELS).notNull(), + checkedAt: datetime("checked_at", { mode: "date" }).notNull(), + }, + (t) => [uniqueIndex("repo_access_user_repo_idx").on(t.userId, t.repoId)], +); + +export const RUN_KINDS = ["review", "analysis", "test"] as const; +export type RunKind = (typeof RUN_KINDS)[number]; +export const HUMAN_REQUEST_STATUSES = ["open", "resolved", "cancelled", "timeout"] as const; + +/** A run waiting for a person (CAPTCHA, bot check). The person works in the live view, then resolves it. */ +export const humanRequests = mysqlTable( + "human_requests", + { + id: id(), + runKind: mysqlEnum("run_kind", RUN_KINDS).notNull(), + runRefId: ref("run_ref_id").notNull(), + repoId: ref("repo_id").notNull(), + reason: varchar("reason", { length: 500 }).notNull(), + status: mysqlEnum("status", HUMAN_REQUEST_STATUSES).notNull().default("open"), + resolvedBy: ref("resolved_by"), + createdAt: createdAt(), + resolvedAt: datetime("resolved_at", { mode: "date" }), + }, + (t) => [index("human_requests_run_idx").on(t.runKind, t.runRefId), index("human_requests_status_idx").on(t.status)], +); + +/** Clicks and keystrokes from the live view, applied in order by the worker. */ +export const humanInputs = mysqlTable( + "human_inputs", + { + id: id(), + requestId: ref("request_id").notNull(), + input: json("input").$type().notNull(), + userId: ref("user_id").notNull(), + createdAt: createdAt(), + }, + (t) => [index("human_inputs_request_idx").on(t.requestId)], +); + +export type HumanInputPayload = + | { type: "click"; x: number; y: number } + | { type: "type"; text: string } + | { type: "key"; key: string } + | { type: "scroll"; dy: number }; + +/** Live log lines of a review, an analysis or a test run (exactly one id is set). */ +export const runEvents = mysqlTable( + "run_events", + { + id: id(), + reviewId: ref("review_id"), + analysisId: ref("analysis_id"), + testRunId: ref("test_run_id"), + seq: int("seq").notNull(), + line: mediumtext("line").notNull(), + createdAt: createdAt(), + }, + (t) => [ + uniqueIndex("run_events_review_seq_idx").on(t.reviewId, t.seq), + uniqueIndex("run_events_analysis_seq_idx").on(t.analysisId, t.seq), + uniqueIndex("run_events_test_seq_idx").on(t.testRunId, t.seq), + ], +); + +export const FINDING_STATUSES = ["proposed", "confirmed", "dismissed"] as const; +export type FindingStatus = (typeof FINDING_STATUSES)[number]; + +export const findings = mysqlTable( + "findings", + { + id: id(), + reviewId: ref("review_id").notNull(), + /** Finding id from the run (F1, F2, ...). */ + findingId: varchar("finding_id", { length: 20 }).notNull(), + data: json("data").$type().notNull(), + status: mysqlEnum("status", FINDING_STATUSES).notNull().default("proposed"), + comment: text("comment"), + ratedBy: ref("rated_by"), + ratedAt: datetime("rated_at", { mode: "date" }), + }, + (t) => [uniqueIndex("findings_review_finding_idx").on(t.reviewId, t.findingId)], +); + +export type User = typeof users.$inferSelect; +export type Repo = typeof repos.$inferSelect; +export type RepoRole = typeof repoRoles.$inferSelect; +export type Review = typeof reviews.$inferSelect; +export type FindingRow = typeof findings.$inferSelect; +export type Analysis = typeof analyses.$inferSelect; +export type TestScriptRow = typeof testScripts.$inferSelect; +export type TestRun = typeof testRuns.$inferSelect; +export type HumanRequest = typeof humanRequests.$inferSelect; diff --git a/packages/db/src/tokens.ts b/packages/db/src/tokens.ts new file mode 100644 index 0000000..36eb7a7 --- /dev/null +++ b/packages/db/src/tokens.ts @@ -0,0 +1,26 @@ +import { refreshAccessToken } from "@claudio/github"; +import type { Db } from "./client.js"; +import { decrypt } from "./crypto.js"; +import { updateUserTokens } from "./queries.js"; +import type { User } from "./schema.js"; + +/** + * The user's GitHub token, renewed first if it is about to expire (OAuth Apps + * with expiring tokens). Null when there is none or it cannot be renewed; the + * person then has to sign in again. + */ +export async function freshAccessToken(db: Db, user: User): Promise { + const access = user.accessTokenEnc ? decrypt(user.accessTokenEnc) : ""; + if (!access) return null; + if (!user.tokenExpiresAt || user.tokenExpiresAt.getTime() - Date.now() > 5 * 60_000) return access; + const clientId = process.env.GITHUB_CLIENT_ID; + const clientSecret = process.env.GITHUB_CLIENT_SECRET; + if (!user.refreshTokenEnc || !clientId || !clientSecret) return null; + try { + const t = await refreshAccessToken(clientId, clientSecret, decrypt(user.refreshTokenEnc)); + await updateUserTokens(db, user.id, t); + return t.accessToken; + } catch { + return null; + } +} diff --git a/packages/db/test/crypto.test.ts b/packages/db/test/crypto.test.ts new file mode 100644 index 0000000..3d405a9 --- /dev/null +++ b/packages/db/test/crypto.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { decrypt, encrypt, secretKey } from "../src/crypto.js"; +import { roleEnvNames, toRepoConfig } from "../src/repo-config.js"; +import type { RepoRole } from "../src/schema.js"; + +const key = secretKey("test-key-with-enough-length"); + +describe("crypto", () => { + it("round-trips and uses a fresh IV per call", () => { + const a = encrypt("geheim", key); + const b = encrypt("geheim", key); + expect(a).not.toBe(b); + expect(decrypt(a, key)).toBe("geheim"); + }); + it("rejects tampered ciphertext and wrong keys", () => { + const token = encrypt("geheim", key); + const parts = token.split("."); + parts[3] = Buffer.from("anders").toString("base64url"); + expect(() => decrypt(parts.join("."), key)).toThrow(); + expect(() => decrypt(token, secretKey("another-key-with-length"))).toThrow(); + }); + it("refuses short keys", () => { + expect(() => secretKey("short")).toThrow(/CLAUDIO_SECRET_KEY/); + }); +}); + +describe("toRepoConfig", () => { + const role = (name: string, extra: Partial = {}): RepoRole => ({ + id: 1, + repoId: 1, + name, + description: null, + usernameEnc: encrypt(`${name}@example.test`, key), + passwordEnc: encrypt(`${name}-pw`, key), + loginPath: "/login", + successUrlContains: null, + recipe: null, + createdAt: new Date(), + ...extra, + }); + + it("maps roles to env var names and keeps credentials out of the config", () => { + const { config, roleEnv } = toRepoConfig( + { fullName: "o/r", notes: "Hinweis", extraAllowedHosts: ["auth.example.test"] }, + [role("dispatcher", { successUrlContains: "/dashboard" }), role("super-admin")], + (v) => decrypt(v, key), + ); + expect(config.repo).toBe("o/r"); + expect(config.notes).toBe("Hinweis"); + expect(config.extraAllowedHosts).toEqual(["auth.example.test"]); + expect(config.roles["dispatcher"]).toMatchObject({ usernameEnv: "CLAUDIO_ROLE_DISPATCHER_USER", successUrlContains: "/dashboard" }); + expect(roleEnvNames("super-admin").passwordEnv).toBe("CLAUDIO_ROLE_SUPER_ADMIN_PASSWORD"); + expect(roleEnv).toEqual({ + CLAUDIO_ROLE_DISPATCHER_USER: "dispatcher@example.test", + CLAUDIO_ROLE_DISPATCHER_PASSWORD: "dispatcher-pw", + CLAUDIO_ROLE_SUPER_ADMIN_USER: "super-admin@example.test", + CLAUDIO_ROLE_SUPER_ADMIN_PASSWORD: "super-admin-pw", + }); + expect(JSON.stringify(config)).not.toContain("-pw"); + }); +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/github/package.json b/packages/github/package.json new file mode 100644 index 0000000..ca59301 --- /dev/null +++ b/packages/github/package.json @@ -0,0 +1,19 @@ +{ + "name": "@claudio/github", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@claudio/core": "workspace:*", + "octokit": "^5.0.5", + "zod": "^4.6.5" + } +} diff --git a/packages/github/src/client.ts b/packages/github/src/client.ts new file mode 100644 index 0000000..7f91d7b --- /dev/null +++ b/packages/github/src/client.ts @@ -0,0 +1,184 @@ +import { Octokit } from "octokit"; +import { extractLinkedIssueNumbers, parseRepo } from "./links.js"; + +export type ChangedFile = { + filename: string; + status: string; + additions: number; + deletions: number; + patch?: string; + previousFilename?: string; +}; + +export type LinkedIssue = { + number: number; + title: string; + body: string; + url: string; + labels: string[]; + state: string; +}; + +export type PullRequestContext = { + owner: string; + repo: string; + number: number; + title: string; + body: string; + url: string; + author?: string; + headRef: string; + headSha: string; + baseRef: string; + draft: boolean; + files: ChangedFile[]; + issues: LinkedIssue[]; +}; + +export function createGithubClient(token: string | undefined = process.env.GITHUB_TOKEN): Octokit { + if (!token) throw new Error("GITHUB_TOKEN is not set"); + return new Octokit({ auth: token }); +} + +export async function fetchPullRequestContext( + octokit: Octokit, + repoFullName: string, + number: number, + opts: { maxPatchChars?: number } = {}, +): Promise { + const { owner, repo } = parseRepo(repoFullName); + const maxPatchChars = opts.maxPatchChars ?? 12_000; + const { data: pr } = await octokit.rest.pulls.get({ owner, repo, pull_number: number }); + const files = await octokit.paginate(octokit.rest.pulls.listFiles, { owner, repo, pull_number: number, per_page: 100 }); + + const issueNumbers = extractLinkedIssueNumbers(pr.body, pr.head.ref).filter((n) => n !== number); + const issues: LinkedIssue[] = []; + for (const n of issueNumbers) { + try { + const { data } = await octokit.rest.issues.get({ owner, repo, issue_number: n }); + if (data.pull_request) continue; // a PR reference, not an issue + issues.push({ + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.html_url, + labels: data.labels.map((l) => (typeof l === "string" ? l : (l.name ?? ""))).filter(Boolean), + state: data.state, + }); + } catch { + // Unresolvable reference (deleted issue, no access): skip silently, the reviewer will see fewer issues. + } + } + + return { + owner, + repo, + number, + title: pr.title, + body: pr.body ?? "", + url: pr.html_url, + author: pr.user?.login, + headRef: pr.head.ref, + headSha: pr.head.sha, + baseRef: pr.base.ref, + draft: Boolean(pr.draft), + files: files.map((f) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + patch: f.patch ? truncate(f.patch, maxPatchChars) : undefined, + previousFilename: f.previous_filename, + })), + issues, + }; +} + +export async function postPullRequestComment(octokit: Octokit, repoFullName: string, number: number, body: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.issues.createComment({ owner, repo, issue_number: number, body }); + return data.html_url; +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : text.slice(0, max) + `\n... [truncated ${text.length - max} chars]`; +} + +export async function getDefaultBranch(octokit: Octokit, repoFullName: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.repos.get({ owner, repo }); + return data.default_branch; +} + +export async function getBranchHead(octokit: Octokit, repoFullName: string, branch: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.repos.getBranch({ owner, repo, branch }); + return data.commit.sha; +} + +export type ChangedSince = { files: { filename: string; status: string }[]; totalCommits: number; truncated: boolean }; + +/** Files changed between two commits (GitHub caps the list at 300 files). */ +export async function changedFilesBetween(octokit: Octokit, repoFullName: string, base: string, head: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ owner, repo, basehead: `${base}...${head}`, per_page: 100 }); + const files = (data.files ?? []).map((f) => ({ filename: f.filename, status: f.status })); + return { files, totalCommits: data.total_commits, truncated: files.length >= 300 }; +} + +export const WEBHOOK_EVENTS = ["pull_request", "issue_comment", "push"]; + +/** Creates the repository webhook that sends PR, comment and push events to Claudio. Returns the hook id. */ +export async function createRepoWebhook(octokit: Octokit, repoFullName: string, url: string, secret: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.repos.createWebhook({ + owner, + repo, + name: "web", + active: true, + events: WEBHOOK_EVENTS, + config: { url, content_type: "json", secret, insecure_ssl: "0" }, + }); + return data.id; +} + +export async function deleteRepoWebhook(octokit: Octokit, repoFullName: string, hookId: number): Promise { + const { owner, repo } = parseRepo(repoFullName); + await octokit.rest.repos.deleteWebhook({ owner, repo, hook_id: hookId }).catch((e: { status?: number }) => { + if (e.status !== 404) throw e; + }); +} + +/** Last delivery status of the webhook, for the settings page. */ +export async function getRepoWebhook(octokit: Octokit, repoFullName: string, hookId: number) { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.repos.getWebhook({ owner, repo, hook_id: hookId }); + return { active: data.active, url: data.config.url ?? "", lastResponse: data.last_response }; +} + +export type CommitState = "pending" | "success" | "failure" | "error"; + +/** Sets the "Claudio" status on a commit; GitHub shows it in the PR's checks and branch protection can require it. */ +export async function setCommitStatus(octokit: Octokit, repoFullName: string, sha: string, state: CommitState, description: string, targetUrl?: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + await octokit.rest.repos.createCommitStatus({ + owner, + repo, + sha, + state, + context: "Claudio", + description: description.slice(0, 140), + target_url: targetUrl, + }); +} + +export async function reactToComment(octokit: Octokit, repoFullName: string, commentId: number, content: "eyes" | "rocket" = "eyes"): Promise { + const { owner, repo } = parseRepo(repoFullName); + await octokit.rest.reactions.createForIssueComment({ owner, repo, comment_id: commentId, content }); +} + +export async function getPullHead(octokit: Octokit, repoFullName: string, number: number): Promise<{ sha: string; branch: string; title: string; open: boolean }> { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.pulls.get({ owner, repo, pull_number: number }); + return { sha: data.head.sha, branch: data.head.ref, title: data.title, open: data.state === "open" }; +} diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts new file mode 100644 index 0000000..e758f7e --- /dev/null +++ b/packages/github/src/index.ts @@ -0,0 +1,4 @@ +export * from "./client.js"; +export * from "./links.js"; +export * from "./webhooks.js"; +export * from "./oauth.js"; diff --git a/packages/github/src/links.ts b/packages/github/src/links.ts new file mode 100644 index 0000000..d6b2fde --- /dev/null +++ b/packages/github/src/links.ts @@ -0,0 +1,24 @@ +/** Extracts GitHub issue numbers linked from a PR body and its head branch name. */ +export function extractLinkedIssueNumbers(body: string | null | undefined, headRef?: string): number[] { + const found = new Set(); + const text = body ?? ""; + // closes #12, fixes #12, resolves #12 (case-insensitive), and owner/repo#12 forms + const keyword = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*(?:[\w.-]+\/[\w.-]+)?#(\d+)/gi; + for (const m of text.matchAll(keyword)) found.add(Number(m[1])); + // plain #12 references in the body + for (const m of text.matchAll(/(?:^|[\s(])#(\d+)\b/g)) found.add(Number(m[1])); + // issue links + for (const m of text.matchAll(/github\.com\/[\w.-]+\/[\w.-]+\/issues\/(\d+)/g)) found.add(Number(m[1])); + // branch names like 123-feature, feature/123-foo, issue-123 + if (headRef) { + const b = headRef.match(/(?:^|[\/_-])(?:issue-?)?(\d{1,6})(?=[\/_-]|$)/); + if (b) found.add(Number(b[1])); + } + return [...found].filter((n) => n > 0); +} + +export function parseRepo(full: string): { owner: string; repo: string } { + const m = full.match(/^([\w.-]+)\/([\w.-]+)$/); + if (!m) throw new Error(`Invalid repository "${full}", expected owner/name`); + return { owner: m[1]!, repo: m[2]! }; +} diff --git a/packages/github/src/oauth.ts b/packages/github/src/oauth.ts new file mode 100644 index 0000000..37a2dd5 --- /dev/null +++ b/packages/github/src/oauth.ts @@ -0,0 +1,28 @@ +/** Tokens from GitHub's OAuth endpoint. Expiry fields are set only when the OAuth App issues expiring tokens. */ +export type TokenSet = { accessToken: string; refreshToken?: string; expiresAt?: Date }; + +type TokenResponse = { access_token?: string; refresh_token?: string; expires_in?: number; error?: string; error_description?: string }; + +async function tokenRequest(body: Record): Promise { + const res = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json()) as TokenResponse; + if (!data.access_token) throw new Error(data.error_description ?? data.error ?? `GitHub antwortete mit ${res.status}`); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : undefined, + }; +} + +export function exchangeCode(clientId: string, clientSecret: string, code: string, redirectUri: string): Promise { + return tokenRequest({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }); +} + +/** Renews an expiring user token (OAuth Apps with "Expire user access tokens"). */ +export function refreshAccessToken(clientId: string, clientSecret: string, refreshToken: string): Promise { + return tokenRequest({ client_id: clientId, client_secret: clientSecret, grant_type: "refresh_token", refresh_token: refreshToken }); +} diff --git a/packages/github/src/webhooks.ts b/packages/github/src/webhooks.ts new file mode 100644 index 0000000..09652f9 --- /dev/null +++ b/packages/github/src/webhooks.ts @@ -0,0 +1,94 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** Verifies GitHub's X-Hub-Signature-256 header against the raw request body. */ +export function verifyWebhookSignature(secret: string, rawBody: string | Buffer, header: string | null | undefined): boolean { + if (!header?.startsWith("sha256=")) return false; + const expected = Buffer.from(`sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`); + const given = Buffer.from(header); + return expected.length === given.length && timingSafeEqual(expected, given); +} + +/** Per-repository automation settings. */ +export type AutomationSettings = { + /** Instance URL of a PR, e.g. https://pr-{pr}.preview.example.de. Placeholders: {pr}, {branch}, {branch_slug}, {sha}. */ + instanceUrlTemplate: string; + onComment: boolean; + onLabel: boolean; + /** Review every opened, reopened or updated (pushed) PR. */ + onPush: boolean; + /** Update the knowledge base when the default branch receives a push. */ + analyzeOnMerge: boolean; + /** Report the result as a commit status on the PR head. */ + commitStatus: boolean; +}; + +export const REVIEW_LABEL = "claudio-review"; +const COMMAND = /^\s*\/claudio\s+review\b(.*)$/im; + +/** Builds a PR's instance URL from the repo's template. */ +export function renderInstanceUrl(template: string, pr: { number: number; branch: string; sha: string }): string { + const slug = pr.branch + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 50); + return template + .replace(/\{pr\}/g, String(pr.number)) + .replace(/\{branch_slug\}/g, slug) + .replace(/\{branch\}/g, encodeURIComponent(pr.branch)) + .replace(/\{sha\}/g, pr.sha); +} + +export type TriggerDecision = + | { kind: "review"; prNumber: number; headSha: string | null; branch: string | null; title: string | null; trigger: "comment" | "label" | "push"; budget?: "quick" | "standard" | "thorough"; commentId?: number } + | { kind: "analysis"; branch: string; headSha: string } + | { kind: "ignore"; reason: string }; + +type Payload = Record; + +/** + * Decides what a GitHub event means for Claudio. Pure: the caller verifies the + * signature, loads the repo's settings and creates the job. + */ +export function decideTrigger(event: string, payload: Payload, settings: AutomationSettings, defaultBranch: string | null): TriggerDecision { + if (event === "ping") return { kind: "ignore", reason: "ping" }; + + if (event === "pull_request") { + const pr = payload.pull_request; + if (!pr) return { kind: "ignore", reason: "no pull request in payload" }; + if (pr.state !== "open") return { kind: "ignore", reason: "pull request is not open" }; + const base = { prNumber: pr.number as number, headSha: pr.head?.sha ?? null, branch: pr.head?.ref ?? null, title: pr.title ?? null }; + if (payload.action === "labeled") { + if (!settings.onLabel) return { kind: "ignore", reason: "label trigger is off" }; + if (payload.label?.name !== REVIEW_LABEL) return { kind: "ignore", reason: `label is not ${REVIEW_LABEL}` }; + return { kind: "review", ...base, trigger: "label" }; + } + if (["opened", "reopened", "synchronize", "ready_for_review"].includes(payload.action)) { + if (!settings.onPush) return { kind: "ignore", reason: "automatic reviews are off" }; + if (pr.draft) return { kind: "ignore", reason: "draft pull request" }; + return { kind: "review", ...base, trigger: "push" }; + } + return { kind: "ignore", reason: `pull_request.${payload.action} is not a trigger` }; + } + + if (event === "issue_comment") { + if (payload.action !== "created") return { kind: "ignore", reason: "comment not created" }; + if (!payload.issue?.pull_request) return { kind: "ignore", reason: "comment is not on a pull request" }; + if (!settings.onComment) return { kind: "ignore", reason: "comment trigger is off" }; + if (payload.comment?.user?.type === "Bot") return { kind: "ignore", reason: "comment by a bot" }; + const m = String(payload.comment?.body ?? "").match(COMMAND); + if (!m) return { kind: "ignore", reason: "no /claudio review command" }; + const budget = m[1]!.match(/\b(quick|standard|thorough)\b/i)?.[1]?.toLowerCase() as "quick" | "standard" | "thorough" | undefined; + return { kind: "review", prNumber: payload.issue.number, headSha: null, branch: null, title: payload.issue.title ?? null, trigger: "comment", budget, commentId: payload.comment?.id }; + } + + if (event === "push") { + const branch = String(payload.ref ?? "").replace(/^refs\/heads\//, ""); + if (!settings.analyzeOnMerge) return { kind: "ignore", reason: "knowledge update on merge is off" }; + if (!defaultBranch || branch !== defaultBranch) return { kind: "ignore", reason: `push to ${branch || "?"}, not the default branch` }; + if (payload.deleted) return { kind: "ignore", reason: "branch deleted" }; + return { kind: "analysis", branch, headSha: payload.after }; + } + + return { kind: "ignore", reason: `event ${event} is not handled` }; +} diff --git a/packages/github/test/links.test.ts b/packages/github/test/links.test.ts new file mode 100644 index 0000000..b78b3a2 --- /dev/null +++ b/packages/github/test/links.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { extractLinkedIssueNumbers, parseRepo } from "../src/links.js"; + +describe("extractLinkedIssueNumbers", () => { + it("finds closing keywords, plain refs, links and branch numbers", () => { + const body = "Closes #12\nSee also #7 and https://github.com/o/r/issues/99\nFixes: o/r#5"; + expect(extractLinkedIssueNumbers(body, "feature/42-neue-fahrt").sort((a, b) => a - b)).toEqual([5, 7, 12, 42, 99]); + }); + it("ignores branch names without issue numbers", () => { + expect(extractLinkedIssueNumbers("", "main")).toEqual([]); + expect(extractLinkedIssueNumbers(null, "feature/foo")).toEqual([]); + }); + it("parses owner/name", () => { + expect(parseRepo("vanderlars/claudio")).toEqual({ owner: "vanderlars", repo: "claudio" }); + expect(() => parseRepo("nope")).toThrow(); + }); +}); diff --git a/packages/github/test/webhooks.test.ts b/packages/github/test/webhooks.test.ts new file mode 100644 index 0000000..637c546 --- /dev/null +++ b/packages/github/test/webhooks.test.ts @@ -0,0 +1,51 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { decideTrigger, renderInstanceUrl, verifyWebhookSignature, type AutomationSettings } from "../src/webhooks.js"; + +const all: AutomationSettings = { instanceUrlTemplate: "https://pr-{pr}.example.test", onComment: true, onLabel: true, onPush: true, analyzeOnMerge: true, commitStatus: true }; +const pr = (extra: Record = {}) => ({ number: 17, state: "open", draft: false, title: "Neue Fahrt", head: { sha: "abc", ref: "feature/16-Neue Fahrt" }, ...extra }); + +describe("signature", () => { + const body = JSON.stringify({ zen: "Keep it logically awesome." }); + const sig = `sha256=${createHmac("sha256", "s3cret").update(body).digest("hex")}`; + it("accepts the right secret and rejects everything else", () => { + expect(verifyWebhookSignature("s3cret", body, sig)).toBe(true); + expect(verifyWebhookSignature("other", body, sig)).toBe(false); + expect(verifyWebhookSignature("s3cret", body + " ", sig)).toBe(false); + expect(verifyWebhookSignature("s3cret", body, null)).toBe(false); + expect(verifyWebhookSignature("s3cret", body, "sha1=abc")).toBe(false); + }); +}); + +describe("instance URL template", () => { + it("fills number, branch slug and sha", () => { + expect(renderInstanceUrl("https://pr-{pr}.example.test", { number: 17, branch: "x", sha: "s" })).toBe("https://pr-17.example.test"); + expect(renderInstanceUrl("https://{branch_slug}.preview.test/?c={sha}", { number: 1, branch: "feature/16-Neue Fahrt", sha: "abc" })).toBe("https://feature-16-neue-fahrt.preview.test/?c=abc"); + }); +}); + +describe("decideTrigger", () => { + it("reviews on the /claudio review comment, with an optional budget", () => { + const d = decideTrigger("issue_comment", { action: "created", issue: { number: 17, title: "T", pull_request: {} }, comment: { id: 9, body: "Bitte prüfen\n/claudio review thorough", user: { type: "User" } } }, all, "main"); + expect(d).toMatchObject({ kind: "review", prNumber: 17, trigger: "comment", budget: "thorough", commentId: 9 }); + }); + it("ignores comments on issues, by bots, or without the command", () => { + expect(decideTrigger("issue_comment", { action: "created", issue: { number: 1 }, comment: { body: "/claudio review" } }, all, "main").kind).toBe("ignore"); + expect(decideTrigger("issue_comment", { action: "created", issue: { number: 1, pull_request: {} }, comment: { body: "/claudio review", user: { type: "Bot" } } }, all, "main").kind).toBe("ignore"); + expect(decideTrigger("issue_comment", { action: "created", issue: { number: 1, pull_request: {} }, comment: { body: "lgtm" } }, all, "main").kind).toBe("ignore"); + }); + it("reviews on the claudio-review label only", () => { + expect(decideTrigger("pull_request", { action: "labeled", label: { name: "claudio-review" }, pull_request: pr() }, all, "main")).toMatchObject({ kind: "review", trigger: "label", headSha: "abc" }); + expect(decideTrigger("pull_request", { action: "labeled", label: { name: "bug" }, pull_request: pr() }, all, "main").kind).toBe("ignore"); + }); + it("reviews pushes when automatic reviews are on, but not drafts", () => { + expect(decideTrigger("pull_request", { action: "synchronize", pull_request: pr() }, all, "main")).toMatchObject({ kind: "review", trigger: "push" }); + expect(decideTrigger("pull_request", { action: "synchronize", pull_request: pr({ draft: true }) }, all, "main").kind).toBe("ignore"); + expect(decideTrigger("pull_request", { action: "opened", pull_request: pr() }, { ...all, onPush: false }, "main").kind).toBe("ignore"); + }); + it("updates the knowledge base on pushes to the default branch only", () => { + expect(decideTrigger("push", { ref: "refs/heads/main", after: "f00" }, all, "main")).toEqual({ kind: "analysis", branch: "main", headSha: "f00" }); + expect(decideTrigger("push", { ref: "refs/heads/feature", after: "f00" }, all, "main").kind).toBe("ignore"); + expect(decideTrigger("push", { ref: "refs/heads/main", after: "f00" }, { ...all, analyzeOnMerge: false }, "main").kind).toBe("ignore"); + }); +}); diff --git a/packages/github/tsconfig.json b/packages/github/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/github/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a72910e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3415 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.19.0 + version: 22.20.5 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^5.0.3 + version: 5.0.3(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + + apps/cli: + dependencies: + '@claudio/agent': + specifier: workspace:* + version: link:../../packages/agent + '@claudio/browser': + specifier: workspace:* + version: link:../../packages/browser + '@claudio/core': + specifier: workspace:* + version: link:../../packages/core + commander: + specifier: ^15.0.0 + version: 15.0.0 + + apps/demo-target: {} + + apps/web: + dependencies: + '@claudio/core': + specifier: workspace:* + version: link:../../packages/core + '@claudio/db': + specifier: workspace:* + version: link:../../packages/db + '@claudio/github': + specifier: workspace:* + version: link:../../packages/github + drizzle-orm: + specifier: ^0.45.4 + version: 0.45.4(mysql2@3.24.5(@types/node@22.20.5)) + marked: + specifier: ^18.1.0 + version: 18.1.0 + octokit: + specifier: ^5.0.5 + version: 5.0.5 + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@sveltejs/adapter-node': + specifier: ^6.0.0 + version: 6.0.0(@sveltejs/kit@3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15))) + '@sveltejs/kit': + specifier: ^3.0.0 + version: 3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.2.0 + version: 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + svelte: + specifier: ^5.57.1 + version: 5.57.2 + svelte-check: + specifier: ^4.6.0 + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.3.0 + version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + + apps/worker: + dependencies: + '@claudio/agent': + specifier: workspace:* + version: link:../../packages/agent + '@claudio/browser': + specifier: workspace:* + version: link:../../packages/browser + '@claudio/core': + specifier: workspace:* + version: link:../../packages/core + '@claudio/db': + specifier: workspace:* + version: link:../../packages/db + '@claudio/github': + specifier: workspace:* + version: link:../../packages/github + + packages/agent: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.3.296 + version: 0.3.296(@anthropic-ai/sdk@0.133.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.32.1(zod@4.6.5))(zod@4.6.5) + '@claudio/browser': + specifier: workspace:* + version: link:../browser + '@claudio/core': + specifier: workspace:* + version: link:../core + '@claudio/github': + specifier: workspace:* + version: link:../github + zod: + specifier: ^4.6.5 + version: 4.6.5 + + packages/browser: + dependencies: + '@claudio/core': + specifier: workspace:* + version: link:../core + '@modelcontextprotocol/sdk': + specifier: ^1.32.1 + version: 1.32.1(zod@4.6.5) + '@playwright/mcp': + specifier: ^0.0.83 + version: 0.0.83 + playwright: + specifier: ^1.64.0 + version: 1.64.0 + zod: + specifier: ^4.6.5 + version: 4.6.5 + + packages/core: + dependencies: + zod: + specifier: ^4.6.5 + version: 4.6.5 + + packages/db: + dependencies: + '@claudio/core': + specifier: workspace:* + version: link:../core + '@claudio/github': + specifier: workspace:* + version: link:../github + drizzle-orm: + specifier: ^0.45.4 + version: 0.45.4(mysql2@3.24.5(@types/node@22.20.5)) + mysql2: + specifier: ^3.24.5 + version: 3.24.5(@types/node@22.20.5) + devDependencies: + drizzle-kit: + specifier: ^0.31.11 + version: 0.31.11 + + packages/github: + dependencies: + '@claudio/core': + specifier: workspace:* + version: link:../core + octokit: + specifier: ^5.0.5 + version: 5.0.5 + zod: + specifier: ^4.6.5 + version: 4.6.5 + +packages: + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.296': + resolution: {integrity: sha512-Wrhy7rxQVUNowQRDpSZD+3MFSBAlN4TINZSpKD8PKpEValCG6x05N855MuITvwgkH0EeoyNLRCsowJaIHa8V9Q==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.296': + resolution: {integrity: sha512-klMOQ1TdA7u6sWZJYw9l5byWYxMiWzbuPhzCgVVY+LJOs4FlhsS3qLXn2RZmIFhQv1u65zwX+2CRSWEJddqfVQ==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.296': + resolution: {integrity: sha512-8DEazoBU4I8HsPrXgSJ1Vq30nCBF7PaKjHz1o/igCz+HZu3qepHztWpoKJKQee5Mt4XsvZLwtKuxQo1F+SC6yQ==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.296': + resolution: {integrity: sha512-+tC1KlUlp/3BQS+EfG8RBrmru4xKwF6hNEm15gNct8k9tNVRLYOftbkNCGJPTKkoBoq0sQ+Omojzwr/W2Dg60A==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.296': + resolution: {integrity: sha512-lMquemenM5CDNZv6g1+xBSfQpat2xiwOQUnY2mwSQKjT+IfE5/F8CgRd38ZBljwGnedPIuKs/DAZMt4YslLF5w==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.296': + resolution: {integrity: sha512-TStCnbg2iBtWJBQeMdO7Xwpx5IYWICmdcf7J9eXhIi4JbahWD+VLRaH5sxB+LLZjdBXekp8piKv6Ne2+arag0w==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.296': + resolution: {integrity: sha512-/epluPyoLm1NQAcHV0LUierKog7c9yyU8BgpZOtGWiM54nqTZKKBuCwANraV3ROfXYFfJdMV4Vehk/8fqxoOqQ==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.296': + resolution: {integrity: sha512-T71Y/hp0v7VtRzTOoxAsIkPa68JpB1d/VvCKTTE9GzTkbjQZHjs3S1DM06LDxSpwjITF7OCVJq14+XjN8M9t/g==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.296': + resolution: {integrity: sha512-Y+cZ2BT8X94mQ9GJOVyIqhjC/nWGvwozUVLjdkzX7aSt+zuo5tgk7Hdv9V7tEUQ9fKnEuiL6moYNWDeHqCD4Lw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.133.0': + resolution: {integrity: sha512-jlqUN8p8ESTJj+GJnA4KjzbEfF1waEoGzRT8jwytlYGFFgv+/DUQOxlYGvYw10UzhD1kwLqYzxvA3FrTB2YWuQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@2.1.4': + resolution: {integrity: sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@octokit/app@16.1.4': + resolution: {integrity: sha512-g70WONQyGoBgqIJtv4O0MUEfOF1iJpTfFt3qAW7HaiwiJ6k607xg/gBezCa4tuq6BpO6qXn7qvq8v9tjWtgGQg==} + engines: {node: '>= 20'} + + '@octokit/auth-app@8.3.1': + resolution: {integrity: sha512-XHWxZsF4mvGTQCv7AOxgby9t9OjVO19FBuKq+QmyBxFrf+B2hR/P5j/MTF8g95bITf8cRtR17KIXThpFNqiKbg==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-app@9.0.5': + resolution: {integrity: sha512-Hn2yAlIDjFLGjAz7t+CRu7e9VYoFW1W3LLyOLyahFoJGlCf4PtDkHKtRET8w/2CJpsrC0FxIF5g5Xtx9E4O78w==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-device@8.0.5': + resolution: {integrity: sha512-kdJtjRhykMLJrMdcoC56XUBDfoUAuWEFKpkDxwYim+bNwYw1MreLolk+PLnwEYT8djm589YepeMuJ8+ECAIzVQ==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-user@6.0.4': + resolution: {integrity: sha512-eya2pMJ9pCBq1jrm1Rf6J3NI+NemEpzDMLo9J/GpRoK0KgdxIVXWX4LESIfwJ+GYeoq6QFIlUJNi1lVp5FBgmw==} + engines: {node: '>= 20'} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/auth-unauthenticated@7.0.5': + resolution: {integrity: sha512-KBzGMM8hRgtGKDe8JqvDdhHj+7PWHRo1qxz9pQ5WT1162DLdC6O5jL+kRFk3VfpCnEfc3bUYZ2vOwI9vx8yUXA==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.8': + resolution: {integrity: sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.5': + resolution: {integrity: sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.5': + resolution: {integrity: sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==} + engines: {node: '>= 20'} + + '@octokit/oauth-app@8.0.4': + resolution: {integrity: sha512-Ji5JpRwAJcbOJkO4ij0tW6+GrQ8efpICnAca3o5xI8/HUNjqJu3hhG4cCZdXAHMKaOaQTFX0Yl+cpu61Rsx94A==} + engines: {node: '>= 20'} + + '@octokit/oauth-authorization-url@8.0.0': + resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-methods@6.0.5': + resolution: {integrity: sha512-/mAz7taDZD7DcV4zcG6TDw3Kr6TOldmYBRpvA62C3WIxRMmcgX8XxsTvVQomqKE7VEk/BgYhUao1aR2tYGyIKg==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/openapi-types@28.0.0': + resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==} + + '@octokit/openapi-types@29.0.1': + resolution: {integrity: sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==} + + '@octokit/openapi-webhooks-types@12.1.0': + resolution: {integrity: sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA==} + + '@octokit/plugin-paginate-graphql@6.0.0': + resolution: {integrity: sha512-crfpnIoFiBtRkvPqOyLOsw12XsveYuY2ieP6uYDosoUegBJpSVxGwut9sxUgFFcll3VTOTqpUf8yGd8x1OmAkQ==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-paginate-rest@15.0.0': + resolution: {integrity: sha512-lw9A9YL5s4VPj+VEx8uMxaxQm2YTgrPDkrLEuZuu18R8TK3oPcXF4K6t52nx6xn1RcogZC9i188sAr/4XYJaQQ==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-retry@8.1.1': + resolution: {integrity: sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=7' + + '@octokit/plugin-throttling@11.0.5': + resolution: {integrity: sha512-LIdrkrUv+DWbKeg/49rGuFJ3SU0d3hUS+B4MhNZLepBoNUFXms8Ic9edJjrlx+zycqJHjrMRudVpVb/bAXM2Lw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': ^7.0.0 + + '@octokit/request-error@7.1.2': + resolution: {integrity: sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.16': + resolution: {integrity: sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@octokit/types@17.0.0': + resolution: {integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==} + + '@octokit/types@18.0.0': + resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==} + + '@octokit/webhooks-methods@6.0.0': + resolution: {integrity: sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==} + engines: {node: '>= 20'} + + '@octokit/webhooks@14.2.0': + resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==} + engines: {node: '>= 20'} + + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@playwright/mcp@0.0.83': + resolution: {integrity: sha512-oNcl+Ae2/IAjhfPeP46BfIkSakfmprY+aOtkv5MjrQ4lPav4/yNtPhL0iq8SlIM90oApWgBDUxaNKvktazUKOg==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-node@6.0.0': + resolution: {integrity: sha512-s7muM4JmJ75OZ8o0G4VRhrEKdi8twuEm3t2o/qQsdlm12hKaX7S/CuUBuw3n983wB/IFBSpzG2ugX0xdGzbKwg==} + peerDependencies: + '@sveltejs/kit': ^3.0.0-next.0 + + '@sveltejs/kit@3.0.1': + resolution: {integrity: sha512-8JMCCq7ZUJELi3hbhb0+c9Rs8qnbHW90vhOfM+t1FSF4fhIpG9/FOsH4IYgcITMhbdkSrrn+Kvd9G+q8A+UO6Q==} + engines: {node: '>=22.17'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^7.0.0 + svelte: ^5.57.1 + typescript: ^6.0.0 + vite: ^8.0.12 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte@7.3.1': + resolution: {integrity: sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + + '@types/aws-lambda@8.10.164': + resolution: {integrity: sha512-XOnrazWcOd6yWPnR7DxqCBPPYciDjHq+NN8LPwwqBCJNXLyPJgKo47fZpJ113+oyAn9Zti0Cf6H4k6yPcE3WFg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + content-type@3.1.1: + resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==} + engines: {node: '>=22'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + drizzle-kit@0.31.11: + resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==} + hasBin: true + + drizzle-orm@0.45.4: + resolution: {integrity: sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@netlify/db': '>=0.4.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@netlify/db': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.4.0: + resolution: {integrity: sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.3: + resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-with-bigint@3.5.12: + resolution: {integrity: sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru.min@1.1.5: + resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + marked@18.1.0: + resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==} + engines: {node: '>= 20'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mysql2@3.24.5: + resolution: {integrity: sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + octokit@5.0.5: + resolution: {integrity: sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw==} + engines: {node: '>= 20'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright-core@1.64.0-alpha-1790635538000: + resolution: {integrity: sha512-pNwaXirhXMRLaRQs4NQ18EpTdtDoFwyPH9FOaaDC7YXG4hpwE2xmwCic/1srbuExVQC7zT9LJSJpDBAru+Vo9A==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0-alpha-1790635538000: + resolution: {integrity: sha512-/5XDUMxpOd/9AojJtWDFIRV3EJZNQ0AwvK69wgt3CPzy8wTgiBG9pX45VfxtqtvIRGwfWzMhrvawNKreG69Snw==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sql-escaper@1.5.2: + resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte@5.57.2: + resolution: {integrity: sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==} + engines: {node: '>=18'} + + tinybench@6.2.1: + resolution: {integrity: sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universal-github-app-jwt@2.2.2: + resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} + + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zimmerframe@1.1.5: + resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.296(@anthropic-ai/sdk@0.133.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.32.1(zod@4.6.5))(zod@4.6.5)': + dependencies: + '@anthropic-ai/sdk': 0.133.0(zod@4.6.5) + '@modelcontextprotocol/sdk': 1.32.1(zod@4.6.5) + zod: 4.6.5 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.296 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.296 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.296 + + '@anthropic-ai/sdk@0.133.0(zod@4.6.5)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.6.5 + + '@babel/runtime@7.29.10': {} + + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.3 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@hono/node-server@2.1.4(hono@4.13.13)': + dependencies: + hono: 4.13.13 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.5)': + dependencies: + '@hono/node-server': 2.1.4(hono@4.13.13) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.13.13 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + transitivePeerDependencies: + - supports-color + + '@octokit/app@16.1.4': + dependencies: + '@octokit/auth-app': 8.3.1 + '@octokit/auth-unauthenticated': 7.0.5 + '@octokit/core': 7.0.8 + '@octokit/oauth-app': 8.0.4 + '@octokit/plugin-paginate-rest': 15.0.0(@octokit/core@7.0.8) + '@octokit/types': 17.0.0 + '@octokit/webhooks': 14.2.0 + + '@octokit/auth-app@8.3.1': + dependencies: + '@octokit/auth-oauth-app': 9.0.5 + '@octokit/auth-oauth-user': 6.0.4 + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + toad-cache: 3.7.4 + universal-github-app-jwt: 2.2.2 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-app@9.0.5': + dependencies: + '@octokit/auth-oauth-device': 8.0.5 + '@octokit/auth-oauth-user': 6.0.4 + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-device@8.0.5': + dependencies: + '@octokit/oauth-methods': 6.0.5 + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-user@6.0.4': + dependencies: + '@octokit/auth-oauth-device': 8.0.5 + '@octokit/oauth-methods': 6.0.5 + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/auth-unauthenticated@7.0.5': + dependencies: + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + + '@octokit/core@7.0.8': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.5 + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.5': + dependencies: + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.5': + dependencies: + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/oauth-app@8.0.4': + dependencies: + '@octokit/auth-oauth-app': 9.0.5 + '@octokit/auth-oauth-user': 6.0.4 + '@octokit/auth-unauthenticated': 7.0.5 + '@octokit/core': 7.0.8 + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/oauth-methods': 6.0.5 + '@types/aws-lambda': 8.10.164 + universal-user-agent: 7.0.3 + + '@octokit/oauth-authorization-url@8.0.0': {} + + '@octokit/oauth-methods@6.0.5': + dependencies: + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/openapi-types@28.0.0': {} + + '@octokit/openapi-types@29.0.1': {} + + '@octokit/openapi-webhooks-types@12.1.0': {} + + '@octokit/plugin-paginate-graphql@6.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 16.0.0 + + '@octokit/plugin-paginate-rest@15.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 17.0.0 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 16.0.0 + + '@octokit/plugin-retry@8.1.1(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/request-error': 7.1.2 + '@octokit/types': 17.0.0 + bottleneck: 2.19.5 + + '@octokit/plugin-throttling@11.0.5(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 17.0.0 + bottleneck: 2.19.5 + + '@octokit/request-error@7.1.2': + dependencies: + '@octokit/types': 18.0.0 + + '@octokit/request@10.0.16': + dependencies: + '@octokit/endpoint': 11.0.5 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + content-type: 3.1.1 + json-with-bigint: 3.5.12 + universal-user-agent: 7.0.3 + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@octokit/types@17.0.0': + dependencies: + '@octokit/openapi-types': 28.0.0 + + '@octokit/types@18.0.0': + dependencies: + '@octokit/openapi-types': 29.0.1 + + '@octokit/webhooks-methods@6.0.0': {} + + '@octokit/webhooks@14.2.0': + dependencies: + '@octokit/openapi-webhooks-types': 12.1.0 + '@octokit/request-error': 7.1.2 + '@octokit/webhooks-methods': 6.0.0 + + '@oxc-project/types@0.153.0': {} + + '@playwright/mcp@0.0.83': + dependencies: + playwright: 1.64.0-alpha-1790635538000 + playwright-core: 1.64.0-alpha-1790635538000 + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@stablelib/base64@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.13(acorn@8.19.0)': + dependencies: + acorn: 8.19.0 + + '@sveltejs/adapter-node@6.0.0(@sveltejs/kit@3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))': + dependencies: + '@sveltejs/kit': 3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + + '@sveltejs/kit@3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + '@standard-schema/spec': 1.1.0 + '@sveltejs/vite-plugin-svelte': 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + cookie: 2.0.1 + devalue: 5.9.4 + esm-env: 1.2.2 + magic-string: 1.4.3 + mrmime: 2.0.1 + sirv: 3.0.2 + svelte: 5.57.2 + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + optionalDependencies: + typescript: 6.0.3 + + '@sveltejs/load-config@0.2.3': {} + + '@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + deepmerge: 4.3.1 + magic-string: 1.4.3 + obug: 2.2.1 + svelte: 5.57.2 + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + vitefu: 1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + + '@types/aws-lambda@8.10.164': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + '@vitest/mocker@5.0.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + + '@vitest/spy@5.0.3': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + acorn@8.19.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + aria-query@5.3.1: {} + + assertion-error@2.0.1: {} + + aws-ssl-profiles@1.1.2: {} + + axobject-query@4.1.0: {} + + before-after-hook@4.0.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bottleneck@2.19.5: {} + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@6.3.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + commander@15.0.0: {} + + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + content-type@3.1.1: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@2.0.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge@4.3.1: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + devalue@5.9.4: {} + + drizzle-kit@0.31.11: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.15 + + drizzle-orm@0.45.4(mysql2@3.24.5(@types/node@22.20.5)): + optionalDependencies: + mysql2: 3.24.5(@types/node@22.20.5) + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + esm-env@1.2.2: {} + + esrap@2.4.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + expect-type@1.4.0: {} + + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.3 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-sha256@1.3.0: {} + + fast-uri@3.1.8: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.13.13: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.3: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + is-property@1.0.2: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + isexe@2.0.0: {} + + jose@6.2.12: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.10 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-with-bigint@3.5.12: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-character@3.0.0: {} + + long@5.3.2: {} + + lru.min@1.1.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + marked@18.1.0: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + mysql2@3.24.5(@types/node@22.20.5): + dependencies: + '@types/node': 22.20.5 + aws-ssl-profiles: 1.1.2 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.5 + named-placeholders: 1.1.6 + sql-escaper: 1.5.2 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.5 + + nanoid@3.3.20: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.2.1: {} + + octokit@5.0.5: + dependencies: + '@octokit/app': 16.1.4 + '@octokit/core': 7.0.8 + '@octokit/oauth-app': 8.0.4 + '@octokit/plugin-paginate-graphql': 6.0.0(@octokit/core@7.0.8) + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.8) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.8) + '@octokit/plugin-retry': 8.1.1(@octokit/core@7.0.8) + '@octokit/plugin-throttling': 11.0.5(@octokit/core@7.0.8) + '@octokit/request-error': 7.1.2 + '@octokit/types': 16.0.0 + '@octokit/webhooks': 14.2.0 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pkce-challenge@5.0.1: {} + + playwright-core@1.64.0: {} + + playwright-core@1.64.0-alpha-1790635538000: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + + playwright@1.64.0-alpha-1790635538000: + dependencies: + playwright-core: 1.64.0-alpha-1790635538000 + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.2: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sql-escaper@1.5.2: {} + + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + + std-env@4.3.0: {} + + svelte-check@4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.2.3 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.57.2 + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + + svelte@5.57.2: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.6.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.19.0) + '@types/estree': 1.0.9 + acorn: 8.19.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.4 + esm-env: 1.2.2 + esrap: 2.4.0 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.5 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tinybench@6.2.1: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + toad-cache@3.7.4: {} + + toidentifier@1.0.1: {} + + totalist@3.0.1: {} + + ts-algebra@2.0.0: {} + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + typescript@6.0.3: {} + + undici-types@6.21.0: {} + + universal-github-app-jwt@2.2.2: {} + + universal-user-agent@7.0.3: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.5 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitefu@1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)): + optionalDependencies: + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + + vitest@5.0.3(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.1 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 22.20.5 + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + wrappy@1.0.2: {} + + zimmerframe@1.1.5: {} + + zod-to-json-schema@3.25.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/s.md b/s.md new file mode 100644 index 0000000..5a3d3ea --- /dev/null +++ b/s.md @@ -0,0 +1,13 @@ +- generic [active] [ref=f4e1]: + - banner [ref=f4e2]: + - strong [ref=f4e3]: Dispo Demo + - link "Übersicht" [ref=f4e4] [cursor=pointer]: + - /url: /dashboard + - link "Fahrten" [ref=f4e5] [cursor=pointer]: + - /url: /trips + - generic [ref=f4e6]: Dana Dispo (dispatcher) + - link "Abmelden" [ref=f4e7] [cursor=pointer]: + - /url: /logout + - main [ref=f4e8]: + - heading "Interner Fehler" [level=1] [ref=f4e9] + - generic [ref=f4e10]: Cannot read properties of null (reading 'toLowerCase') \ No newline at end of file diff --git a/tools/create-deploy-kubeconfig.sh b/tools/create-deploy-kubeconfig.sh new file mode 100755 index 0000000..d0ce175 --- /dev/null +++ b/tools/create-deploy-kubeconfig.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Sets up the credentials for the GitHub Actions deploy job (release.yml → job "deploy"): +# 1. applies k8s/admin/github-deployer.yaml (ServiceAccount + Role + RoleBinding + token Secret) +# 2. builds a kubeconfig for that ServiceAccount from the CURRENT kubectl context +# 3. verifies the permissions and stores the kubeconfig as repository secret KUBECONFIG_B64 (via gh) +# Run as a cluster admin with the target cluster selected: ./tools/create-deploy-kubeconfig.sh [--yes] +# Env: NAMESPACE (claudio), GH_REPO (VanDerLars/Claudio), SET_GH_SECRET=0 to only print the command. +set -euo pipefail + +NS=${NAMESPACE:-claudio} +SA=github-deployer +REPO=${GH_REPO:-VanDerLars/Claudio} +MANIFEST="$(dirname "$0")/../k8s/admin/github-deployer.yaml" + +for tool in kubectl base64; do + command -v "$tool" >/dev/null || { echo "missing tool: $tool" >&2; exit 1; } +done + +CTX=$(kubectl config current-context) +SERVER=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.server}') +echo "== Target: context $CTX, server $SERVER, namespace $NS ==" +if [ "${1:-}" != "--yes" ]; then + read -rp "Continue? [y/N] " answer + [ "$answer" = "y" ] || { echo "aborted"; exit 1; } +fi + +echo "== Applying $MANIFEST ==" +kubectl apply -f "$MANIFEST" + +echo "== Waiting for the token controller ==" +TOKEN="" +for _ in $(seq 1 30); do + TOKEN=$(kubectl -n "$NS" get secret "$SA-token" -o jsonpath='{.data.token}' 2>/dev/null | base64 -d || true) + [ -n "$TOKEN" ] && break + sleep 1 +done +[ -n "$TOKEN" ] || { echo "token of secret $SA-token was not populated" >&2; exit 1; } +CA=$(kubectl -n "$NS" get secret "$SA-token" -o jsonpath='{.data.ca\.crt}') + +OUT=$(mktemp) +chmod 600 "$OUT" +trap 'rm -f "$OUT"' EXIT +cat > "$OUT" </dev/null 2>&1; then + echo " cluster CA does not match the external endpoint – falling back to insecure-skip-tls-verify" >&2 + kubectl config set-cluster claudio --insecure-skip-tls-verify=true >/dev/null + kubectl config unset clusters.claudio.certificate-authority-data >/dev/null + kubectl -n "$NS" get deployments >/dev/null +fi +for check in "patch deployments" "patch services" "patch configmaps" "patch ingresses.networking.k8s.io" "get pods" "get pods/log"; do + # shellcheck disable=SC2086 + printf ' can %-36s %s\n' "$check" "$(kubectl -n "$NS" auth can-i $check)" +done +printf ' can %-36s %s (must be no)\n' "get secrets" "$(kubectl -n "$NS" auth can-i get secrets || true)" +printf ' can %-36s %s (must be no)\n' "patch namespaces" "$(kubectl auth can-i patch namespaces || true)" +unset KUBECONFIG + +if [ "${SET_GH_SECRET:-1}" = 1 ] && command -v gh >/dev/null; then + echo "== Storing repository secret KUBECONFIG_B64 in $REPO ==" + base64 <"$OUT" | tr -d '\n' | gh secret set KUBECONFIG_B64 --repo "$REPO" + echo "done – the next push to main deploys via GitHub Actions" +else + echo "== gh not used – store the secret yourself (the file is deleted when this script exits) ==" + echo " base64 < KUBECONFIG_FILE | tr -d '\\n' | gh secret set KUBECONFIG_B64 --repo $REPO" + read -rp "Press enter to delete $OUT ..." _ +fi diff --git a/tools/create-k8s-secret.sh b/tools/create-k8s-secret.sh new file mode 100755 index 0000000..f3f7077 --- /dev/null +++ b/tools/create-k8s-secret.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Creates or updates the single Kubernetes secret `claudio-secrets` interactively: +# .dockerconfigjson – ghcr.io login for pulling the private image (verified against the registry) +# DATABASE_URL – MySQL connection string (password is URL-encoded) +# DATABASE_MIGRATION_URL – optional: user with DDL privileges for the migrate init container (kept if already set) +# CLAUDIO_SECRET_KEY – encrypts tokens and credentials in the database (kept if the secret already has one) +# GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET – the GitHub OAuth App for the login +# Run it in an interactive terminal: ./tools/create-k8s-secret.sh +set -euo pipefail + +NS=${NAMESPACE:-claudio} +IMAGE_REPO=${IMAGE_REPO:-vanderlars/claudio} +SECRET=claudio-secrets + +for tool in kubectl node curl base64 openssl; do + command -v "$tool" >/dev/null || { echo "missing tool: $tool" >&2; exit 1; } +done +kubectl get namespace "$NS" >/dev/null 2>&1 || { echo "namespace $NS does not exist – run: kubectl apply -f k8s/namespace.yaml" >&2; exit 1; } + +existing() { kubectl -n "$NS" get secret "$SECRET" -o jsonpath="{.data.$1}" 2>/dev/null | base64 -d || true; } + +echo "== Registry login (ghcr.io) ==" +read -rp "GitHub user: " GH_USER +read -rsp "GitHub PAT with scope read:packages: " GH_PAT; echo +[ -n "$GH_PAT" ] || { echo "PAT must not be empty" >&2; exit 1; } +if curl -fsS -u "$GH_USER:$GH_PAT" "https://ghcr.io/token?scope=repository:${IMAGE_REPO}:pull" >/dev/null; then + echo " ghcr.io accepted the credentials" +else + echo " ghcr.io rejected the credentials (check user, token scope read:packages, org SSO authorization)" >&2 + exit 1 +fi + +echo "== Database ==" +read -rp "MySQL host[:port] (e.g. mysql.internal:3306): " DB_HOST +[ -n "$DB_HOST" ] || { echo "host must not be empty" >&2; exit 1; } +read -rp "MySQL database [claudio]: " DB_NAME; DB_NAME=${DB_NAME:-claudio} +read -rp "MySQL user [claudio]: " DB_USER; DB_USER=${DB_USER:-claudio} +read -rsp "MySQL password: " DB_PASS; echo +[ -n "$DB_PASS" ] || { echo "password must not be empty" >&2; exit 1; } +enc() { node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"; } +DATABASE_URL="mysql://$(enc "$DB_USER"):$(enc "$DB_PASS")@${DB_HOST}/${DB_NAME}" + +echo "== Migration user (optional) ==" +echo " Migrations need CREATE, ALTER, DROP, INDEX, REFERENCES. Leave empty if the app user has them (or to keep an existing value)." +read -rp "MySQL migration user (empty = use the app user): " MIG_USER +MIG_ARGS=() +if [ -n "$MIG_USER" ]; then + read -rsp "MySQL migration password: " MIG_PASS; echo + [ -n "$MIG_PASS" ] || { echo "password must not be empty" >&2; exit 1; } + MIG_ARGS=(--from-literal=DATABASE_MIGRATION_URL="mysql://$(enc "$MIG_USER"):$(enc "$MIG_PASS")@${DB_HOST}/${DB_NAME}") +else + MIG_EXISTING=$(existing DATABASE_MIGRATION_URL) + if [ -n "$MIG_EXISTING" ]; then + echo " keeping the existing DATABASE_MIGRATION_URL" + MIG_ARGS=(--from-literal=DATABASE_MIGRATION_URL="$MIG_EXISTING") + fi +fi + +echo "== GitHub OAuth App (login) ==" +echo " Callback URL of the app: https:///auth/github/callback" +read -rp "Client ID: " GH_CLIENT_ID +[ -n "$GH_CLIENT_ID" ] || GH_CLIENT_ID=$(existing GITHUB_CLIENT_ID) +[ -n "$GH_CLIENT_ID" ] || { echo "client id must not be empty" >&2; exit 1; } +read -rsp "Client secret (empty = keep existing): " GH_CLIENT_SECRET; echo +[ -n "$GH_CLIENT_SECRET" ] || GH_CLIENT_SECRET=$(existing GITHUB_CLIENT_SECRET) +[ -n "$GH_CLIENT_SECRET" ] || { echo "client secret must not be empty" >&2; exit 1; } + +echo "== Encryption key ==" +# Never replace an existing key: tokens, role credentials, webhook secrets and Anthropic keys are encrypted with it +ENC_KEY=$(existing CLAUDIO_SECRET_KEY) +if [ -n "$ENC_KEY" ]; then + echo " keeping the existing key" +else + ENC_KEY=$(openssl rand -base64 32) + echo " generated a new key – back it up (password manager), without it all stored credentials are lost" +fi + +DOCKERCFG=$(printf '{"auths":{"ghcr.io":{"auth":"%s"}}}' "$(printf '%s:%s' "$GH_USER" "$GH_PAT" | base64 | tr -d '\n')") + +kubectl -n "$NS" create secret generic "$SECRET" \ + --type=kubernetes.io/dockerconfigjson \ + --from-literal=.dockerconfigjson="$DOCKERCFG" \ + --from-literal=DATABASE_URL="$DATABASE_URL" \ + --from-literal=CLAUDIO_SECRET_KEY="$ENC_KEY" \ + --from-literal=GITHUB_CLIENT_ID="$GH_CLIENT_ID" \ + --from-literal=GITHUB_CLIENT_SECRET="$GH_CLIENT_SECRET" \ + ${MIG_ARGS[@]+"${MIG_ARGS[@]}"} \ + --dry-run=client -o yaml | kubectl apply -f - + +unset GH_PAT DB_PASS DOCKERCFG DATABASE_URL ENC_KEY MIG_PASS MIG_ARGS MIG_EXISTING GH_CLIENT_SECRET +echo +echo "Secret $SECRET in namespace $NS is ready:" +echo " registry user $GH_USER, database $DB_USER@$DB_HOST/$DB_NAME, OAuth client $GH_CLIENT_ID" +echo "Next: check host in k8s/ingress.yaml and ORIGIN/PUBLIC_BASE_URL in k8s/configmap.yaml, then: kubectl apply -f k8s/" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..5660f5b --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "isolatedModules": true, + "noUncheckedIndexedAccess": true + } +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..6104657 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["packages/*/test/**/*.test.ts", "apps/*/test/**/*.test.ts"], + }, +});